quizapp
51.4%
Statements
8929/17386
cmd
0.6%
8/1294
internal
55.4%
8921/16092
quizapp cmd
0.6%
Statements
8/1294
adm
0.0%
0/208
cli-worker
4.8%
8/165
reset-db
0.0%
0/83
server
0.0%
0/102
setup-test-db
0.0%
0/591
worker
0.0%
0/145
quizapp cmd adm
0.0%
Statements
0/208
commands
0.0%
0/161
main.go
0.0%
0/47
quizapp cmd adm main.go
0.0%
Statements
0/161
db.go
0.0%
0/43
translations.go
0.0%
0/25
user.go
0.0%
0/77
utils.go
0.0%
0/16
quizapp cmd adm commands db.go
0.0%
Statements
0/43
1
// Package commands provides CLI commands for the admin tool
2
package commands
3

4
import (
5
    "context"
6
    "database/sql"
7
    "os"
8

9
    "quizapp/internal/observability"
10
    "quizapp/internal/services"
11
    contextutils "quizapp/internal/utils"
12

13
    "github.com/spf13/cobra"
14
)
15

16
// DatabaseCommands returns the database management commands
17
func DatabaseCommands(userService *services.UserService, logger *observability.Logger, db *sql.DB) *cobra.Command {
18
    dbCmd := &cobra.Command{
19
        Use:   "db",
20
        Short: "Database management commands",
21
        Long: `Database management commands for the quiz application.
22

23
Available commands:
24
  stats     - Show database statistics
25
  cleanup   - Run database cleanup operations`,
26
    }
27

28
    // Add subcommands
29
    dbCmd.AddCommand(statsCmd(userService, logger, db))
30
    dbCmd.AddCommand(cleanupCmd(logger, db))
31

32
    return dbCmd
33
}
34

35
// statsCmd returns the stats command
36
func statsCmd(userService *services.UserService, logger *observability.Logger, db *sql.DB) *cobra.Command {
37
    return &cobra.Command{
38
        Use:   "stats",
39
        Short: "Show database statistics",
40
        Long:  `Show database statistics including user counts and other metrics.`,
41
        RunE:  runStats(userService, logger, db),
42
    }
43
}
44

45
// cleanupCmd returns the cleanup command
46
func cleanupCmd(logger *observability.Logger, db *sql.DB) *cobra.Command {
47
    var statsOnly bool
48

49
    cmd := &cobra.Command{
50
        Use:   "cleanup",
51
        Short: "Run database cleanup operations",
52
        Long: `Run database cleanup operations to remove old data.
53

54
This command will:
55
- Remove questions with legacy question types
56
- Remove orphaned user responses
57

58
Use --stats flag to see what would be cleaned up without actually performing the cleanup.`,
59
        RunE: runCleanup(logger, &statsOnly, db),
60
    }
61

62
    // Add flags
63
    cmd.Flags().BoolVar(&statsOnly, "stats", false, "Only show cleanup statistics, don't perform cleanup")
64

65
    return cmd
66
}
67

68
// runStats returns a function that shows database statistics
69
func runStats(userService *services.UserService, logger *observability.Logger, db *sql.DB) func(cmd *cobra.Command, args []string) error {
70
    return func(_ *cobra.Command, _ []string) error {
71
        ctx := context.Background()
72

73
        // Log diagnostic information
74
        logger.Info(ctx, "Diagnostic info", map[string]interface{}{"config_file": os.Getenv("QUIZ_CONFIG_FILE"), "database": getDatabaseInfo(db)})
75

76
        logger.Info(ctx, "Showing database statistics", map[string]interface{}{})
77

78
        // Get user statistics
79
        users, err := userService.GetAllUsers(ctx)
80
        if err != nil {
81
            logger.Error(ctx, "Failed to get user statistics", err, map[string]interface{}{})
82
            return contextutils.WrapErrorf(contextutils.ErrInternalError, "failed to get user statistics: %v", err)
83
        }
84

85
        logger.Info(ctx, "Database statistics", map[string]interface{}{"total_users": len(users), "database": "PostgreSQL", "status": "Connected"})
86

87
        return nil
88
    }
89
}
90

91
// runCleanup returns a function that runs database cleanup
92
func runCleanup(logger *observability.Logger, statsOnly *bool, db *sql.DB) func(cmd *cobra.Command, args []string) error {
93
    return func(_ *cobra.Command, _ []string) error {
94
        ctx := context.Background()
95

96
        // Log diagnostic information
97
        logger.Info(ctx, "Diagnostic info", map[string]interface{}{"config_file": os.Getenv("QUIZ_CONFIG_FILE"), "database": getDatabaseInfo(db)})
98

99
        logger.Info(ctx, "Running database cleanup", map[string]interface{}{"stats_only": *statsOnly})
100

101
        // Use the database connection passed as parameter
102
        if db == nil {
103
            return contextutils.WrapErrorf(contextutils.ErrInternalError, "database connection not available")
104
        }
105

106
        // Initialize cleanup service
107
        cleanupService := services.NewCleanupServiceWithLogger(db, logger)
108

109
        if *statsOnly {
110
            // Show cleanup statistics only
111
            stats, err := cleanupService.GetCleanupStats(ctx)
112
            if err != nil {
113
                logger.Error(ctx, "Failed to get cleanup stats", err, map[string]interface{}{"stats_only": true})
114
                return contextutils.WrapErrorf(contextutils.ErrInternalError, "failed to get cleanup stats: %v", err)
115
            }
116

117
            logger.Info(ctx, "Database cleanup statistics", map[string]interface{}{"legacy_questions": stats["legacy_questions"], "orphaned_responses": stats["orphaned_responses"]})
118

119
            total := stats["legacy_questions"] + stats["orphaned_responses"]
120
            if total == 0 {
121
                logger.Info(ctx, "No cleanup needed - database is clean", map[string]interface{}{"total": total})
122
            } else {
123
                logger.Info(ctx, "Cleanup would remove items", map[string]interface{}{"total": total})
124
            }
125
            return nil
126
        }
127

128
        // Run full cleanup
129
        logger.Info(ctx, "Starting database cleanup", map[string]interface{}{"service": "cleanup"})
130

131
        if err := cleanupService.RunFullCleanup(ctx); err != nil {
132
            logger.Error(ctx, "Cleanup failed", err, map[string]interface{}{"service": "cleanup"})
133
            return contextutils.WrapErrorf(contextutils.ErrInternalError, "cleanup failed: %v", err)
134
        }
135

136
        logger.Info(ctx, "Database cleanup completed successfully", map[string]interface{}{"service": "cleanup"})
137
        return nil
138
    }
139
}
140


			
quizapp cmd adm commands translations.go
0.0%
Statements
0/25
1
// Package commands provides CLI commands for the admin tool
2
package commands
3

4
import (
5
    "context"
6
    "database/sql"
7
    "fmt"
8

9
    "quizapp/internal/observability"
10
    "quizapp/internal/services"
11
    contextutils "quizapp/internal/utils"
12

13
    "github.com/spf13/cobra"
14
)
15

16
// TranslationCommands returns the translation management commands
17
func TranslationCommands(logger *observability.Logger, db *sql.DB) *cobra.Command {
18
    translationCmd := &cobra.Command{
19
        Use:   "translation",
20
        Short: "Translation cache management commands",
21
        Long: `Translation cache management commands for the quiz application.
22

23
Available commands:
24
  cleanup   - Remove expired translation cache entries`,
25
    }
26

27
    // Add subcommands
28
    translationCmd.AddCommand(translationCleanupCmd(logger, db))
29

30
    return translationCmd
31
}
32

33
// translationCleanupCmd returns the cleanup command for translation cache
34
func translationCleanupCmd(logger *observability.Logger, db *sql.DB) *cobra.Command {
35
    var dryRun bool
36

37
    cmd := &cobra.Command{
38
        Use:   "cleanup",
39
        Short: "Remove expired translation cache entries",
40
        Long: `Remove expired translation cache entries from the database.
41

42
This command will:
43
- Delete all translation cache entries that have expired (older than 30 days)
44
- Report the number of entries deleted
45

46
Use --dry-run flag to see what would be cleaned up without actually performing the cleanup.`,
47
        RunE: runTranslationCleanup(logger, &dryRun, db),
48
    }
49

50
    cmd.Flags().BoolVar(&dryRun, "dry-run", false, "Show what would be cleaned up without actually performing the cleanup")
51

52
    return cmd
53
}
54

55
// runTranslationCleanup executes the translation cache cleanup
56
func runTranslationCleanup(logger *observability.Logger, dryRun *bool, db *sql.DB) func(*cobra.Command, []string) error {
57
    return func(_ *cobra.Command, _ []string) error {
58
        ctx := context.Background()
59
        cacheRepo := services.NewTranslationCacheRepository(db, logger)
60

61
        if *dryRun {
62
            // Count expired entries without deleting
63
            var count int64
64
            err := db.QueryRowContext(ctx, "SELECT COUNT(*) FROM translation_cache WHERE expires_at < NOW()").Scan(&count)
65
            if err != nil {
66
                logger.Error(ctx, "Failed to count expired translation cache entries", err)
67
                return contextutils.WrapError(err, "failed to count expired entries")
68
            }
69

70
            fmt.Printf("Dry run: Would delete %d expired translation cache entries\n", count)
71
            return nil
72
        }
73

74
        // Perform actual cleanup
75
        count, err := cacheRepo.CleanupExpiredTranslations(ctx)
76
        if err != nil {
77
            logger.Error(ctx, "Failed to cleanup expired translation cache entries", err)
78
            return contextutils.WrapError(err, "failed to cleanup expired entries")
79
        }
80

81
        fmt.Printf("Successfully deleted %d expired translation cache entries\n", count)
82
        logger.Info(ctx, "Translation cache cleanup completed", map[string]interface{}{
83
            "deleted_count": count,
84
        })
85

86
        return nil
87
    }
88
}
89


			
quizapp cmd adm commands user.go
0.0%
Statements
0/77
1
package commands
2

3
import (
4
    "context"
5
    "fmt"
6
    "os"
7
    "syscall"
8

9
    "golang.org/x/term"
10

11
    "quizapp/internal/observability"
12
    "quizapp/internal/services"
13
    contextutils "quizapp/internal/utils"
14

15
    "github.com/spf13/cobra"
16
)
17

18
// UserCommands returns the user management commands
19
func UserCommands(userService *services.UserService, logger *observability.Logger, databaseURL string) *cobra.Command {
20
    userCmd := &cobra.Command{
21
        Use:   "user",
22
        Short: "User management commands",
23
        Long: `User management commands for the quiz application.
24

25
Available commands:
26
  list     - List all users
27
  reset-password - Reset password for a specific user`,
28
    }
29

30
    // Add subcommands
31
    userCmd.AddCommand(listCmd(userService, logger, databaseURL))
32
    userCmd.AddCommand(resetPasswordCmd(userService, logger))
33

34
    return userCmd
35
}
36

37
// listCmd returns the list command
38
func listCmd(userService *services.UserService, logger *observability.Logger, databaseURL string) *cobra.Command {
39
    return &cobra.Command{
40
        Use:   "list",
41
        Short: "List all users",
42
        Long:  `List all users in the database with their basic information.`,
43
        RunE:  runListUsers(userService, logger, databaseURL),
44
    }
45
}
46

47
// resetPasswordCmd returns the reset-password command
48
func resetPasswordCmd(userService *services.UserService, logger *observability.Logger) *cobra.Command {
49
    return &cobra.Command{
50
        Use:   "reset-password [username]",
51
        Short: "Reset password for a user",
52
        Long:  `Reset the password for a specific user. If username is not provided, you will be prompted for it.`,
53
        RunE:  runResetPassword(userService, logger),
54
    }
55
}
56

57
// runListUsers returns a function that lists all users
58
func runListUsers(userService *services.UserService, logger *observability.Logger, databaseURL string) func(cmd *cobra.Command, args []string) error {
59
    return func(_ *cobra.Command, _ []string) error {
60
        ctx := context.Background()
61

62
        // Show diagnostic information
63
        logger.Info(ctx, "Admin command diagnostics", map[string]interface{}{"config_file": os.Getenv("QUIZ_CONFIG_FILE"), "database_url": maskDatabaseURL(databaseURL)})
64

65
        logger.Info(ctx, "Listing all users", map[string]interface{}{})
66

67
        users, err := userService.GetAllUsers(ctx)
68
        if err != nil {
69
            logger.Error(ctx, "Failed to get users", err, map[string]interface{}{})
70
            return contextutils.WrapError(err, "failed to get users")
71
        }
72

73
        if len(users) == 0 {
74
            logger.Info(ctx, "No users found in the database", nil)
75
            return nil
76
        }
77

78
        // Print header to stdout (user-facing table)
79
        fmt.Printf("%-5s %-20s %-30s %-15s %-10s %-10s %-10s\n", "ID", "Username", "Email", "Language", "Level", "AI Enabled", "Created")
80
        fmt.Println(string(make([]byte, 120))) // Print 120 dashes
81

82
        // Print each user
83
        for _, user := range users {
84
            aiEnabled := "No"
85
            if user.AIEnabled.Valid && user.AIEnabled.Bool {
86
                aiEnabled = "Yes"
87
            }
88

89
            email := "N/A"
90
            if user.Email.Valid {
91
                email = user.Email.String
92
            }
93

94
            language := "N/A"
95
            if user.PreferredLanguage.Valid {
96
                language = user.PreferredLanguage.String
97
            }
98

99
            level := "N/A"
100
            if user.CurrentLevel.Valid {
101
                level = user.CurrentLevel.String
102
            }
103

104
            fmt.Printf("%-5d %-20s %-30s %-15s %-10s %-10s %-10s\n",
105
                user.ID,
106
                user.Username,
107
                email,
108
                language,
109
                level,
110
                aiEnabled,
111
                user.CreatedAt.Format("2006-01-02"),
112
            )
113
        }
114

115
        logger.Info(ctx, "Listed users", map[string]interface{}{"total": len(users)})
116
        return nil
117
    }
118
}
119

120
// runResetPassword returns a function that resets a user's password
121
func runResetPassword(userService *services.UserService, logger *observability.Logger) func(cmd *cobra.Command, args []string) error {
122
    return func(_ *cobra.Command, args []string) error {
123
        ctx := context.Background()
124

125
        var username string
126
        var newPassword string
127

128
        // Get username from args or prompt
129
        if len(args) > 0 {
130
            username = args[0]
131
        } else {
132
            fmt.Print("Enter username: ")
133
            if _, err := fmt.Scanln(&username); err != nil {
134
                return contextutils.WrapErrorf(contextutils.ErrInternalError, "failed to read username: %v", err)
135
            }
136
        }
137

138
        if username == "" {
139
            return contextutils.ErrorWithContextf("username is required")
140
        }
141

142
        // Prompt for password securely
143
        fmt.Print("Enter new password: ")
144
        passwordBytes, err := term.ReadPassword(int(syscall.Stdin))
145
        if err != nil {
146
            return contextutils.WrapErrorf(contextutils.ErrInternalError, "failed to read password: %v", err)
147
        }
148
        newPassword = string(passwordBytes)
149
        fmt.Println() // New line after password input
150

151
        if newPassword == "" {
152
            return contextutils.ErrorWithContextf("password cannot be empty")
153
        }
154

155
        // Confirm password
156
        fmt.Print("Confirm new password: ")
157
        confirmBytes, err := term.ReadPassword(int(syscall.Stdin))
158
        if err != nil {
159
            return contextutils.WrapErrorf(contextutils.ErrInternalError, "failed to read password confirmation: %v", err)
160
        }
161
        confirmPassword := string(confirmBytes)
162
        fmt.Println() // New line after password input
163

164
        if newPassword != confirmPassword {
165
            return contextutils.ErrorWithContextf("passwords do not match")
166
        }
167

168
        logger.Info(ctx, "Resetting password for user", map[string]interface{}{
169
            "username": username,
170
        })
171

172
        // Get user by username
173
        user, err := userService.GetUserByUsername(ctx, username)
174
        if err != nil {
175
            logger.Error(ctx, "Failed to get user", err, map[string]interface{}{"username": username})
176
            return contextutils.WrapErrorf(contextutils.ErrInternalError, "failed to get user '%s': %v", username, err)
177
        }
178

179
        if user == nil {
180
            logger.Error(ctx, "User not found", nil, map[string]interface{}{"username": username})
181
            return contextutils.ErrorWithContextf("user '%s' not found", username)
182
        }
183

184
        // Update the password
185
        err = userService.UpdateUserPassword(ctx, user.ID, newPassword)
186
        if err != nil {
187
            logger.Error(ctx, "Failed to update password", err, map[string]interface{}{
188
                "username": username,
189
                "user_id":  user.ID,
190
            })
191
            return contextutils.WrapErrorf(contextutils.ErrInternalError, "failed to update password for user '%s': %v", username, err)
192
        }
193

194
        fmt.Printf("â Password successfully reset for user '%s' (ID: %d)\n", username, user.ID)
195
        logger.Info(ctx, "Password reset successful", map[string]interface{}{
196
            "username": username,
197
            "user_id":  user.ID,
198
        })
199

200
        return nil
201
    }
202
}
203


			
quizapp cmd adm commands utils.go
0.0%
Statements
0/16
1
package commands
2

3
import (
4
    "database/sql"
5
    "fmt"
6
    "strings"
7
)
8

9
// maskDatabaseURL masks sensitive parts of the database URL for display
10
func maskDatabaseURL(url string) string {
11
    // Simple masking for display purposes
12
    if strings.Contains(url, "@") {
13
        parts := strings.Split(url, "@")
14
        if len(parts) == 2 {
15
            return "postgres://***:***@" + parts[1]
16
        }
17
    }
18
    return url
19
}
20

21
// getDatabaseInfo returns database connection information
22
func getDatabaseInfo(db *sql.DB) string {
23
    if db == nil {
24
        return "Not connected"
25
    }
26

27
    // Try to get database name
28
    var dbName string
29
    err := db.QueryRow("SELECT current_database()").Scan(&dbName)
30
    if err != nil {
31
        return "Connected (unknown database)"
32
    }
33

34
    // Try to get host information
35
    var host string
36
    err = db.QueryRow("SELECT inet_server_addr()::text").Scan(&host)
37
    if err != nil {
38
        return fmt.Sprintf("Connected to %s", dbName)
39
    }
40

41
    return fmt.Sprintf("Connected to %s on %s", dbName, host)
42
}
43


			
quizapp cmd adm main.go
0.0%
Statements
0/47
1
// Package main provides the main entry point for the quiz application admin CLI tool.
2
package main
3

4
import (
5
    "context"
6
    "fmt"
7
    "os"
8

9
    "quizapp/cmd/adm/commands"
10
    "quizapp/internal/config"
11
    "quizapp/internal/database"
12
    "quizapp/internal/observability"
13
    "quizapp/internal/services"
14

15
    "github.com/spf13/cobra"
16
)
17

18
// Global variables for shared resources
19
var (
20
    cfg         *config.Config
21
    logger      *observability.Logger
22
    userService *services.UserService
23
)
24

25
func main() {
26
    ctx := context.Background()
27

28
    // Set default config file if not already set
29
    if os.Getenv("QUIZ_CONFIG_FILE") == "" {
30
        // Try to find the config file in common locations
31
        defaultPaths := []string{
32
            "../merged.config.yaml",    // From backend/cmd/adm/
33
            "../../merged.config.yaml", // From backend/cmd/adm/ (alternative)
34
            "merged.config.yaml",       // Current directory
35
        }
36

37
        for _, path := range defaultPaths {
38
            if _, err := os.Stat(path); err == nil {
39
                if err := os.Setenv("QUIZ_CONFIG_FILE", path); err != nil {
40
                    fmt.Fprintf(os.Stderr, "Failed to set QUIZ_CONFIG_FILE environment variable: %v\n", err)
41
                    os.Exit(1)
42
                }
43
                break
44
            }
45
        }
46
    }
47

48
    // Load configuration
49
    var err error
50
    cfg, err = config.NewConfig()
51
    if err != nil {
52
        fmt.Fprintf(os.Stderr, "Failed to load configuration: %v\n", err)
53
        os.Exit(1)
54
    }
55

56
    // Override log level for admin tool
57
    cfg.Server.LogLevel = "error"
58

59
    // Disable all OpenTelemetry features for admin CLI to avoid connection errors
60
    cfg.OpenTelemetry.EnableTracing = false
61
    cfg.OpenTelemetry.EnableMetrics = false
62
    cfg.OpenTelemetry.EnableLogging = false
63

64
    // Setup observability (tracing/metrics/logging)
65
    tp, mp, loggerInstance, err := observability.SetupObservability(&cfg.OpenTelemetry, "quiz-admin")
66
    if err != nil {
67
        fmt.Fprintf(os.Stderr, "Failed to initialize observability: %v\n", err)
68
        os.Exit(1)
69
    }
70

71
    // Store logger globally
72
    logger = loggerInstance
73

74
    // Defer cleanup
75
    defer func() {
76
        if tp != nil {
77
            if err := tp.Shutdown(context.TODO()); err != nil {
78
                logger.Warn(ctx, "Error shutting down tracer provider", map[string]interface{}{"error": err.Error(), "provider": "tracer"})
79
            }
80
        }
81
        if mp != nil {
82
            if err := mp.Shutdown(context.TODO()); err != nil {
83
                logger.Warn(ctx, "Error shutting down meter provider", map[string]interface{}{"error": err.Error(), "provider": "meter"})
84
            }
85
        }
86
    }()
87

88
    // Initialize database manager
89
    dbManager := database.NewManager(logger)
90

91
    // Initialize database connection with configuration (no migrations for admin tool)
92
    db, err := dbManager.InitDBWithoutMigrations(cfg.Database)
93
    if err != nil {
94
        logger.Error(ctx, "Failed to connect to database", err, map[string]interface{}{"db_url": cfg.Database.URL})
95
        os.Exit(1)
96
    }
97
    defer func() {
98
        if err := db.Close(); err != nil {
99
            logger.Warn(ctx, "Warning: failed to close database connection", map[string]interface{}{"error": err.Error(), "db_url": cfg.Database.URL})
100
        }
101
    }()
102

103
    // Initialize services
104
    userService = services.NewUserServiceWithLogger(db, cfg, logger)
105

106
    // Create the root command
107
    rootCmd := &cobra.Command{
108
        Use:   "adm",
109
        Short: "Quiz Application Administration Tool",
110
        Long: `Quiz Application Administration Tool
111

112
A comprehensive CLI tool for administering the quiz application.
113
Provides commands for user management, database operations, and system administration.`,
114

115
        Run: func(cmd *cobra.Command, _ []string) {
116
            // Show help if no subcommand provided
117
            if err := cmd.Help(); err != nil {
118
                fmt.Printf("Error showing help: %v\n", err)
119
            }
120
        },
121
    }
122

123
    // Add subcommands with initialized services
124
    rootCmd.AddCommand(commands.UserCommands(userService, logger, cfg.Database.URL))
125
    rootCmd.AddCommand(commands.DatabaseCommands(userService, logger, db))
126
    rootCmd.AddCommand(commands.TranslationCommands(logger, db))
127

128
    // Execute the command
129
    if err := rootCmd.Execute(); err != nil {
130
        os.Exit(1)
131
    }
132
}
133


			
quizapp cmd cli-worker
4.8%
Statements
8/165
main.go
4.8%
8/165
quizapp cmd cli-worker main.go
4.8%
Statements
8/165
1
// Package main provides a CLI tool for running the worker to generate questions for a specific user.
2
package main
3

4
import (
5
    "context"
6
    "flag"
7
    "fmt"
8
    "os"
9
    "strings"
10
    "time"
11

12
    "quizapp/internal/config"
13
    "quizapp/internal/database"
14
    "quizapp/internal/models"
15
    "quizapp/internal/observability"
16
    "quizapp/internal/services"
17
    "quizapp/internal/worker"
18
)
19

20
func main() {
21
    ctx := context.Background()
22
    // Define command line flags
23
    var (
24
        username     = flag.String("username", "", "Username to generate questions for (required)")
25
        level        = flag.String("level", "", "Override user's current level (optional)")
26
        language     = flag.String("language", "", "Override user's preferred language (optional)")
27
        questionType = flag.String("type", "vocabulary", "Question type: vocabulary, fill_blank, qa, reading_comprehension")
28
        topic        = flag.String("topic", "", "Specific topic for questions (optional)")
29
        count        = flag.Int("count", 5, "Number of questions to generate")
30
        aiProvider   = flag.String("ai-provider", "", "Override AI provider (optional)")
31
        aiModel      = flag.String("ai-model", "", "Override AI model (optional)")
32
        aiAPIKey     = flag.String("ai-api-key", "", "Override AI API key (optional)")
33
        help         = flag.Bool("help", false, "Show help message")
34
    )
35

36
    flag.Parse()
37

38
    if *help {
39
        printUsage(nil)
40
        return
41
    }
42

43
    if *username == "" {
44
        fmt.Fprintln(os.Stderr, "Error: --username flag is required")
45
        os.Exit(1)
46
    }
47

48
    // Load configuration
49
    cfg, err := config.NewConfig()
50
    if err != nil {
51
        fmt.Fprintf(os.Stderr, "Failed to load configuration: %v\n", err)
52
        os.Exit(1)
53
    }
54

55
    // Setup observability (tracing/metrics/logging)
56
    tp, mp, logger, err := observability.SetupObservability(&cfg.OpenTelemetry, "quiz-cli-worker")
57
    if err != nil {
58
        fmt.Fprintf(os.Stderr, "Failed to initialize observability: %v\n", err)
59
        os.Exit(1)
60
    }
61
    defer func() {
62
        if tp != nil {
63
            if err := tp.Shutdown(context.TODO()); err != nil {
64
                logger.Warn(ctx, "Error shutting down tracer provider", map[string]interface{}{"error": err.Error()})
65
            }
66
        }
67
        if mp != nil {
68
            if err := mp.Shutdown(context.TODO()); err != nil {
69
                logger.Warn(ctx, "Error shutting down meter provider", map[string]interface{}{"error": err.Error()})
70
            }
71
        }
72
    }()
73

74
    logger.Info(ctx, "Starting quiz CLI worker", map[string]interface{}{
75
        "username":      *username,
76
        "question_type": *questionType,
77
        "count":         *count,
78
    })
79

80
    // Validate question type
81
    validTypes := map[string]models.QuestionType{
82
        "vocabulary":            models.Vocabulary,
83
        "fill_blank":            models.FillInBlank,
84
        "qa":                    models.QuestionAnswer,
85
        "reading_comprehension": models.ReadingComprehension,
86
    }
87

88
    qType, valid := validTypes[strings.ToLower(*questionType)]
89
    if !valid {
90
        logger.Error(ctx, "Invalid question type", nil, map[string]interface{}{"question_type": *questionType})
91
        fmt.Fprintf(os.Stderr, "Error: Invalid question type '%s'\n", *questionType)
92
        os.Exit(1)
93
    }
94

95
    // Validate level if provided
96
    if *level != "" {
97
        if !isValidLevel(*level, cfg.GetAllLevels()) {
98
            logger.Error(ctx, "Invalid level", nil, map[string]interface{}{"level": *level})
99
            fmt.Fprintf(os.Stderr, "Error: Invalid level '%s'\n", *level)
100
            os.Exit(1)
101
        }
102
    }
103

104
    // Validate language if provided (use dynamic list from config)
105
    validLanguages := cfg.GetLanguages()
106
    if *language != "" {
107
        if !isValidLanguage(*language, validLanguages) {
108
            logger.Error(ctx, "Invalid language", nil, map[string]interface{}{"language": *language})
109
            fmt.Fprintf(os.Stderr, "Error: Invalid language '%s'\n", *language)
110
            os.Exit(1)
111
        }
112
    }
113

114
    // Initialize database manager with logger
115
    dbManager := database.NewManager(logger)
116

117
    // Initialize database connection with configuration
118
    db, err := dbManager.InitDBWithoutMigrations(cfg.Database)
119
    if err != nil {
120
        logger.Error(ctx, "Failed to connect to database", err, map[string]interface{}{"db_url": cfg.Database.URL})
121
        fmt.Fprintf(os.Stderr, "Failed to connect to database: %v\n", err)
122
        os.Exit(1)
123
    }
124
    defer func() {
125
        if err := db.Close(); err != nil {
126
            logger.Warn(ctx, "Warning: failed to close database connection", map[string]interface{}{"error": err.Error(), "db_url": cfg.Database.URL})
127
        }
128
    }()
129

130
    // Initialize services
131
    userService := services.NewUserServiceWithLogger(db, cfg, logger)
132
    learningService := services.NewLearningServiceWithLogger(db, cfg, logger)
133
    // Create question service
134
    questionService := services.NewQuestionServiceWithLogger(db, learningService, cfg, logger)
135
    // Create usage stats service
136
    usageStatsService := services.NewUsageStatsService(cfg, db, logger)
137
    aiService := services.NewAIService(cfg, logger, usageStatsService)
138
    workerService := services.NewWorkerServiceWithLogger(db, logger)
139

140
    // Get user by username
141
    user, err := userService.GetUserByUsername(ctx, *username)
142
    if err != nil {
143
        logger.Error(ctx, "Failed to get user", err)
144
        fmt.Fprintf(os.Stderr, "Failed to get user: %v\n", err)
145
        os.Exit(1)
146
    }
147
    if user == nil {
148
        logger.Error(ctx, "User not found", nil, map[string]interface{}{"username": *username})
149
        fmt.Fprintf(os.Stderr, "User not found: %s\n", *username)
150
        os.Exit(1)
151
        return
152
    }
153
    logger.Info(ctx, "Found user", map[string]interface{}{"username": user.Username, "user_id": user.ID})
154

155
    // Apply AI overrides if provided
156
    if *aiProvider != "" {
157
        user.AIProvider.String = *aiProvider
158
        user.AIProvider.Valid = true
159
        user.AIEnabled.Bool = true
160
        user.AIEnabled.Valid = true
161
    }
162
    if *aiModel != "" {
163
        user.AIModel.String = *aiModel
164
        user.AIModel.Valid = true
165
    }
166
    if *aiAPIKey != "" {
167
        // Set AI provider and API key if provided
168
        if *aiProvider != "" && *aiAPIKey != "" {
169
            if err := userService.SetUserAPIKey(ctx, user.ID, *aiProvider, *aiAPIKey); err != nil {
170
                logger.Error(ctx, "Failed to set API key", err)
171
                fmt.Fprintf(os.Stderr, "Failed to set API key: %v\n", err)
172
                os.Exit(1)
173
            }
174
        } else if *aiAPIKey != "" {
175
            // If only API key is provided, use the user's current AI provider
176
            if err := userService.SetUserAPIKey(ctx, user.ID, user.AIProvider.String, *aiAPIKey); err != nil {
177
                logger.Error(ctx, "Failed to set API key", err)
178
                fmt.Fprintf(os.Stderr, "Failed to set API key: %v\n", err)
179
                os.Exit(1)
180
            }
181
        }
182
    }
183

184
    // Check if user has AI enabled (after potential overrides)
185
    if !user.AIEnabled.Valid || !user.AIEnabled.Bool {
186
        logger.Warn(ctx, "User does not have AI enabled", map[string]interface{}{"username": user.Username, "user_id": user.ID})
187
        logger.Info(ctx, "You may want to enable AI for this user first or use --ai-provider flag")
188
    }
189

190
    // Determine language and level to use
191
    languageToUse := user.PreferredLanguage.String
192
    if *language != "" {
193
        languageToUse = *language
194
    }
195

196
    levelToUse := user.CurrentLevel.String
197
    if *level != "" {
198
        levelToUse = *level
199
    }
200

201
    // Validate that we have required settings
202
    if languageToUse == "" {
203
        logger.Error(ctx, "No language specified", nil, map[string]interface{}{"username": user.Username, "user_id": user.ID})
204
        fmt.Fprintln(os.Stderr, "Error: No language specified. User has no preferred language and --language flag not provided")
205
        os.Exit(1)
206
    }
207
    if levelToUse == "" {
208
        logger.Error(ctx, "No level specified", nil, map[string]interface{}{"username": user.Username, "user_id": user.ID})
209
        fmt.Fprintln(os.Stderr, "Error: No level specified. User has no current level and --level flag not provided")
210
        os.Exit(1)
211
    }
212

213
    // Print configuration
214
    fmt.Printf("=== CLI Worker Configuration ===\n")
215
    fmt.Printf("User: %s (ID: %d)\n", user.Username, user.ID)
216
    fmt.Printf("Language: %s\n", languageToUse)
217
    fmt.Printf("Level: %s\n", levelToUse)
218
    fmt.Printf("Question Type: %s\n", qType)
219
    fmt.Printf("Count: %d\n", *count)
220
    if *topic != "" {
221
        fmt.Printf("Topic: %s\n", *topic)
222
    }
223
    if user.AIProvider.Valid && user.AIProvider.String != "" {
224
        fmt.Printf("AI Provider: %s\n", user.AIProvider.String)
225
    }
226
    if user.AIModel.Valid && user.AIModel.String != "" {
227
        fmt.Printf("AI Model: %s\n", user.AIModel.String)
228
    }
229
    fmt.Printf("===============================\n\n")
230

231
    // Create email service
232
    emailService := services.CreateEmailService(cfg, logger)
233
    // Create daily question service
234
    dailyQuestionService := services.NewDailyQuestionService(db, logger, questionService, learningService)
235

236
    // Create story service
237
    storyService := services.NewStoryService(db, cfg, logger)
238

239
    // Create word of the day service
240
    wordOfTheDayService := services.NewWordOfTheDayService(db, logger)
241

242
    // Create translation cache repository
243
    translationCacheRepo := services.NewTranslationCacheRepository(db, logger)
244

245
    // Create a minimal worker instance for question generation
246
    workerInstance := worker.NewWorker(userService, questionService, aiService, learningService, workerService, dailyQuestionService, wordOfTheDayService, storyService, emailService, nil, translationCacheRepo, "cli", cfg, logger)
247

248
    // Create context with timeout
249
    ctx, cancel := context.WithTimeout(ctx, config.CLIWorkerTimeout)
250
    defer cancel()
251

252
    // Log CLI worker start with structured logging
253
    logger.Info(ctx, "CLI worker starting question generation", map[string]interface{}{
254
        "user_id":       user.ID,
255
        "username":      user.Username,
256
        "question_type": qType,
257
        "count":         *count,
258
        "language":      languageToUse,
259
        "level":         levelToUse,
260
    })
261

262
    // Generate questions
263
    fmt.Printf("Starting question generation...\n")
264
    startTime := time.Now()
265

266
    result, err := workerInstance.GenerateQuestionsForUser(ctx, user, languageToUse, levelToUse, qType, *count, *topic)
267

268
    duration := time.Since(startTime)
269

270
    if err != nil {
271
        fmt.Printf("\nâ Question generation failed after %v\n", duration)
272
        fmt.Printf("Error: %v\n", err)
273
        os.Exit(1)
274
    }
275

276
    fmt.Printf("\nâ Question generation completed successfully in %v\n", duration)
277
    fmt.Printf("Result: %s\n", result)
278
}
279

280
8x
func isValidLevel(level string, validLevels []string) bool {
281
8x
    for _, validLevel := range validLevels {
282
33x
        if strings.EqualFold(level, validLevel) {
283
6x
            return true
284
6x
        }
285
    }
286
2x
    return false
287
}
288

289
6x
func isValidLanguage(language string, validLanguages []string) bool {
290
6x
    for _, validLang := range validLanguages {
291
18x
        if strings.EqualFold(language, validLang) {
292
4x
            return true
293
4x
        }
294
    }
295
2x
    return false
296
}
297

298
func printUsage(cfg *config.Config) {
299
    if cfg == nil {
300
        fmt.Fprintf(os.Stderr, "Error: Configuration is missing or invalid.\n")
301
        return
302
    }
303
    fmt.Printf("Usage: cli-worker [flags]\n")
304
    fmt.Printf("Flags:\n")
305
    fmt.Printf("  -language string\tLanguage to generate questions for\n")
306
    fmt.Printf("  -level string\tLevel to generate questions for\n")
307
    fmt.Printf("  -type string\tQuestion type (vocabulary, fill_in_blank, qa, reading_comprehension)\n")
308
    fmt.Printf("  -count int\tNumber of questions to generate (default 1)\n")
309
    fmt.Printf("  -topic string\tTopic for question generation\n")
310
    fmt.Printf("  -provider string\tAI provider to use\n")
311
    fmt.Printf("  -model string\tAI model to use\n")
312
    fmt.Printf("  -help\tShow this help message\n\n")
313

314
    fmt.Printf("Valid levels: %s\n", strings.Join(cfg.GetAllLevels(), ", "))
315
    fmt.Printf("Valid languages: %s\n", strings.Join(cfg.GetLanguages(), ", "))
316
    if cfg.Providers != nil {
317
        providerNames := make([]string, 0, len(cfg.Providers))
318
        for _, p := range cfg.Providers {
319
            providerNames = append(providerNames, p.Code)
320
        }
321
        fmt.Printf("Valid providers: %s\n", strings.Join(providerNames, ", "))
322
    } else {
323
        fmt.Printf("Valid providers: \n")
324
    }
325
}
326


			
quizapp cmd reset-db
0.0%
Statements
0/83
main.go
0.0%
0/83
quizapp cmd reset-db main.go
0.0%
Statements
0/83
1
// Package main provides a small CLI utility to reset the application's
2
// database to a clean state. It is intended for local development and
3
// testing only and will permanently delete all data when run.
4
package main
5

6
import (
7
    "bufio"
8
    "context"
9
    "fmt"
10
    "os"
11
    "strings"
12

13
    "quizapp/internal/config"
14
    "quizapp/internal/database"
15
    "quizapp/internal/observability"
16
    "quizapp/internal/services"
17
)
18

19
// fatalIfErr logs the error with context and exits
20
func fatalIfErr(ctx context.Context, logger *observability.Logger, msg string, err error, fields map[string]interface{}) {
21
    logger.Error(ctx, msg, err, fields)
22
    os.Exit(1)
23
}
24

25
func main() {
26
    ctx := context.Background()
27

28
    // Load configuration first
29
    cfg, err := config.NewConfig()
30
    if err != nil {
31
        fmt.Fprintf(os.Stderr, "Failed to load configuration: %v\n", err)
32
        os.Exit(1)
33
    }
34

35
    // Setup observability (tracing/metrics/logging)
36
    tp, mp, logger, err := observability.SetupObservability(&cfg.OpenTelemetry, "reset-db")
37
    if err != nil {
38
        fmt.Fprintf(os.Stderr, "Failed to initialize observability: %v\n", err)
39
        os.Exit(1)
40
    }
41
    defer func() {
42
        if tp != nil {
43
            if err := tp.Shutdown(context.TODO()); err != nil {
44
                logger.Warn(ctx, "Error shutting down tracer provider", map[string]interface{}{"error": err.Error(), "provider": "tracer"})
45
            }
46
        }
47
        if mp != nil {
48
            if err := mp.Shutdown(context.TODO()); err != nil {
49
                logger.Warn(ctx, "Error shutting down meter provider", map[string]interface{}{"error": err.Error(), "provider": "meter"})
50
            }
51
        }
52
    }()
53

54
    fmt.Println("âï  DATABASE RESET UTILITY âï")
55
    fmt.Println("=============================")
56
    fmt.Println("This will PERMANENTLY DELETE ALL DATA in the database!")
57
    fmt.Println("This includes:")
58
    fmt.Println("- All users (including admin)")
59
    fmt.Println("- All questions")
60
    fmt.Println("- All user responses")
61
    fmt.Println("- All performance metrics")
62
    fmt.Println("")
63

64
    logger.Info(ctx, "Attempting to reset the database", map[string]interface{}{"service": "reset-db"})
65

66
    if cfg.Database.URL == "" {
67
        fatalIfErr(ctx, logger, "Database URL is empty", nil, map[string]interface{}{"error": "Database URL is empty. Cannot proceed with reset."})
68
    }
69

70
    // Print database info
71
    fmt.Println("ð Database Information:")
72
    fmt.Printf("URL: %s\n", maskDatabaseURL(cfg.Database.URL))
73
    fmt.Println("")
74

75
    // Confirm with user
76
    if !confirmReset() {
77
        fmt.Println("Reset cancelled.")
78
        return
79
    }
80

81
    // Initialize database manager with logger
82
    dbManager := database.NewManager(logger)
83

84
    // Initialize database connection with configuration
85
    db, err := dbManager.InitDBWithConfig(cfg.Database)
86
    if err != nil {
87
        fatalIfErr(ctx, logger, "Failed to connect to database", err, map[string]interface{}{"db_url": cfg.Database.URL})
88
    }
89
    defer func() {
90
        if err := db.Close(); err != nil {
91
            logger.Warn(ctx, "Warning: failed to close database connection", map[string]interface{}{"error": err.Error(), "db_url": cfg.Database.URL})
92
        }
93
    }()
94

95
    // Initialize services
96
    userService := services.NewUserServiceWithLogger(db, cfg, logger)
97

98
    // Drop all tables
99
    fmt.Println("ðï  Dropping all tables...")
100
    logger.Info(ctx, "Dropping all tables", map[string]interface{}{"db_url": cfg.Database.URL, "service": "reset-db"})
101

102
    // For now, we'll just run migrations which will recreate the schema
103
    // In a real implementation, you might want to add a DropAllTables method to the database manager
104

105
    // Run migrations
106
    fmt.Println("ð Running database migrations...")
107
    logger.Info(ctx, "Running database migrations", map[string]interface{}{"db_url": cfg.Database.URL, "service": "reset-db"})
108

109
    if err := dbManager.RunMigrations(db); err != nil {
110
        fatalIfErr(ctx, logger, "Failed to run migrations", err, map[string]interface{}{"db_url": cfg.Database.URL})
111
    }
112

113
    fmt.Println("â Database migrations completed successfully!")
114
    logger.Info(ctx, "Database migrations completed successfully", map[string]interface{}{"db_url": cfg.Database.URL, "service": "reset-db"})
115

116
    // Recreate admin user immediately
117
    fmt.Printf("Recreating admin user '%s'...\n", cfg.Server.AdminUsername)
118
    logger.Info(ctx, "Recreating admin user", map[string]interface{}{"username": cfg.Server.AdminUsername, "service": "reset-db"})
119
    // Ensure admin user exists
120
    if err := userService.EnsureAdminUserExists(ctx, cfg.Server.AdminUsername, cfg.Server.AdminPassword); err != nil {
121
        fatalIfErr(ctx, logger, "Failed to ensure admin user exists", err, map[string]interface{}{"admin_username": cfg.Server.AdminUsername})
122
    }
123

124
    fmt.Println("â Admin user recreated successfully!")
125
    logger.Info(ctx, "Admin user recreated successfully", map[string]interface{}{"username": cfg.Server.AdminUsername, "service": "reset-db"})
126
    fmt.Println("")
127
    // Print admin credentials
128
    fmt.Printf("\nAdmin user credentials:\n")
129
    fmt.Printf("   Username: %s\n", cfg.Server.AdminUsername)
130
    fmt.Printf("   Password: %s\n", cfg.Server.AdminPassword)
131
    fmt.Println("")
132
    fmt.Println("â Database is now ready to use!")
133
    fmt.Println("- You can now start the server or use the existing running instance")
134
    fmt.Println("- Use the credentials above to log into the application")
135
}
136

137
func confirmReset() bool {
138
    reader := bufio.NewReader(os.Stdin)
139

140
    for {
141
        fmt.Print("Are you sure you want to reset the database? (type 'yes' to confirm): ")
142
        response, err := reader.ReadString('\n')
143
        if err != nil {
144
            fmt.Println("Error reading input:", err)
145
            continue
146
        }
147

148
        response = strings.TrimSpace(strings.ToLower(response))
149

150
        switch response {
151
        case "yes":
152
            return true
153
        case "no", "":
154
            return false
155
        default:
156
            fmt.Println("Please type 'yes' to confirm or 'no' to cancel.")
157
        }
158
    }
159
}
160

161
func maskDatabaseURL(url string) string {
162
    // Simple masking for display purposes
163
    if strings.Contains(url, "@") {
164
        parts := strings.Split(url, "@")
165
        if len(parts) == 2 {
166
            return "postgres://***:***@" + parts[1]
167
        }
168
    }
169
    return url
170
}
171


			
quizapp cmd server
0.0%
Statements
0/102
main.go
0.0%
0/102
quizapp cmd server main.go
0.0%
Statements
0/102
1
// Package main provides the main entry point for the quiz application backend server.
2
// It sets up the HTTP server, database connections, middleware, and API routes.
3
package main
4

5
import (
6
    "context"
7
    "fmt"
8
    "os"
9
    "os/signal"
10
    "syscall"
11
    "time"
12

13
    "quizapp/internal/config"
14
    "quizapp/internal/di"
15
    "quizapp/internal/handlers"
16
    "quizapp/internal/observability"
17
    contextutils "quizapp/internal/utils"
18

19
    "github.com/gin-gonic/gin"
20
)
21

22
// Application encapsulates the main application logic and can be tested
23
type Application struct {
24
    container di.ServiceContainerInterface
25
    router    *gin.Engine
26
}
27

28
// NewApplication creates a new application instance
29
func NewApplication(container di.ServiceContainerInterface) (*Application, error) {
30
    // Get services from container
31
    userService, err := container.GetUserService()
32
    if err != nil {
33
        return nil, contextutils.WrapError(err, "failed to get user service")
34
    }
35

36
    questionService, err := container.GetQuestionService()
37
    if err != nil {
38
        return nil, contextutils.WrapError(err, "failed to get question service")
39
    }
40

41
    learningService, err := container.GetLearningService()
42
    if err != nil {
43
        return nil, contextutils.WrapError(err, "failed to get learning service")
44
    }
45

46
    aiService, err := container.GetAIService()
47
    if err != nil {
48
        return nil, contextutils.WrapError(err, "failed to get AI service")
49
    }
50

51
    workerService, err := container.GetWorkerService()
52
    if err != nil {
53
        return nil, contextutils.WrapError(err, "failed to get worker service")
54
    }
55

56
    dailyQuestionService, err := container.GetDailyQuestionService()
57
    if err != nil {
58
        return nil, contextutils.WrapError(err, "failed to get daily question service")
59
    }
60

61
    storyService, err := container.GetStoryService()
62
    if err != nil {
63
        return nil, contextutils.WrapError(err, "failed to get story service")
64
    }
65

66
    oauthService, err := container.GetOAuthService()
67
    if err != nil {
68
        return nil, contextutils.WrapError(err, "failed to get OAuth service")
69
    }
70

71
    generationHintService, err := container.GetGenerationHintService()
72
    if err != nil {
73
        return nil, contextutils.WrapError(err, "failed to get generation hint service")
74
    }
75

76
    conversationService, err := container.GetConversationService()
77
    if err != nil {
78
        return nil, contextutils.WrapError(err, "failed to get conversation service")
79
    }
80

81
    translationService, err := container.GetTranslationService()
82
    if err != nil {
83
        return nil, contextutils.WrapError(err, "failed to get translation service")
84
    }
85

86
    snippetsService, err := container.GetSnippetsService()
87
    if err != nil {
88
        return nil, contextutils.WrapError(err, "failed to get snippets service")
89
    }
90

91
    usageStatsService, err := container.GetUsageStatsService()
92
    if err != nil {
93
        return nil, contextutils.WrapError(err, "failed to get usage stats service")
94
    }
95

96
    wordOfTheDayService, err := container.GetWordOfTheDayService()
97
    if err != nil {
98
        return nil, contextutils.WrapError(err, "failed to get word of the day service")
99
    }
100

101
    authAPIKeyService, err := container.GetAuthAPIKeyService()
102
    if err != nil {
103
        return nil, contextutils.WrapError(err, "failed to get auth API key service")
104
    }
105

106
    // Use the router factory
107
    router := handlers.NewRouter(
108
        container.GetConfig(),
109
        userService,
110
        questionService,
111
        learningService,
112
        aiService,
113
        workerService,
114
        dailyQuestionService,
115
        storyService,
116
        conversationService,
117
        oauthService,
118
        generationHintService,
119
        translationService,
120
        snippetsService,
121
        usageStatsService,
122
        wordOfTheDayService,
123
        authAPIKeyService,
124
        container.GetLogger(),
125
    )
126

127
    return &Application{
128
        container: container,
129
        router:    router,
130
    }, nil
131
}
132

133
// Run starts the application and returns an error if it fails to start
134
func (a *Application) Run(ctx context.Context, port string) error {
135
    // Start server in a goroutine
136
    serverErr := make(chan error, 1)
137
    go func() {
138
        if err := a.router.Run(":" + port); err != nil {
139
            serverErr <- err
140
        }
141
    }()
142

143
    // Wait for shutdown signal or server error
144
    select {
145
    case <-ctx.Done():
146
        return nil // Context cancelled, graceful shutdown
147
    case err := <-serverErr:
148
        return contextutils.WrapError(err, "server failed")
149
    }
150
}
151

152
// Shutdown gracefully shuts down the application
153
func (a *Application) Shutdown(ctx context.Context) error {
154
    return a.container.Shutdown(ctx)
155
}
156

157
func main() {
158
    ctx, cancel := context.WithCancel(context.Background())
159
    defer cancel()
160

161
    // Setup graceful shutdown
162
    shutdownCh := make(chan os.Signal, 1)
163
    signal.Notify(shutdownCh, syscall.SIGINT, syscall.SIGTERM)
164

165
    // Load configuration
166
    cfg, err := config.NewConfig()
167
    if err != nil {
168
        fmt.Fprintf(os.Stderr, "Failed to load configuration: %v\n", err)
169
        os.Exit(1)
170
    }
171

172
    // Setup observability (tracing/metrics/logging)
173
    tp, mp, logger, err := observability.SetupObservability(&cfg.OpenTelemetry, "quiz-backend")
174
    if err != nil {
175
        fmt.Fprintf(os.Stderr, "Failed to initialize observability: %v\n", err)
176
        os.Exit(1)
177
    }
178
    defer func() {
179
        shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second)
180
        defer shutdownCancel()
181

182
        if tp != nil {
183
            if err := tp.Shutdown(shutdownCtx); err != nil {
184
                logger.Warn(ctx, "Error shutting down tracer provider", map[string]interface{}{"error": err.Error(), "provider": "tracer"})
185
            }
186
        }
187
        if mp != nil {
188
            if err := mp.Shutdown(shutdownCtx); err != nil {
189
                logger.Warn(ctx, "Error shutting down meter provider", map[string]interface{}{"error": err.Error(), "provider": "meter"})
190
            }
191
        }
192
    }()
193

194
    logger.Info(ctx, "Starting quiz backend service", map[string]interface{}{
195
        "port":     cfg.Server.Port,
196
        "logLevel": cfg.Server.LogLevel,
197
    })
198

199
    // Initialize dependency injection container
200
    container := di.NewServiceContainer(cfg, logger)
201

202
    // Initialize all services
203
    if err := container.Initialize(ctx); err != nil {
204
        logger.Error(ctx, "Failed to initialize services", err, nil)
205
        os.Exit(1)
206
    }
207

208
    // Ensure admin user exists
209
    if err := container.EnsureAdminUser(ctx); err != nil {
210
        logger.Error(ctx, "Failed to ensure admin user exists", err, map[string]interface{}{"admin_username": cfg.Server.AdminUsername})
211
        os.Exit(1)
212
    }
213

214
    // Create application instance
215
    app, err := NewApplication(container)
216
    if err != nil {
217
        logger.Error(ctx, "Failed to create application", err, nil)
218
        os.Exit(1)
219
    }
220

221
    // Start application in a goroutine
222
    appErr := make(chan error, 1)
223
    go func() {
224
        if err := app.Run(ctx, cfg.Server.Port); err != nil {
225
            appErr <- err
226
        }
227
    }()
228

229
    // Wait for shutdown signal or application error
230
    select {
231
    case <-shutdownCh:
232
        logger.Info(ctx, "Received shutdown signal, shutting down gracefully", nil)
233
    case err := <-appErr:
234
        logger.Error(ctx, "Application failed", err, nil)
235
        os.Exit(1)
236
    }
237

238
    // Graceful shutdown
239
    shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second)
240
    defer shutdownCancel()
241

242
    // Shutdown application
243
    if err := app.Shutdown(shutdownCtx); err != nil {
244
        logger.Error(ctx, "Error during application shutdown", err, nil)
245
        os.Exit(1)
246
    }
247

248
    logger.Info(ctx, "Shutdown completed successfully", nil)
249
}
250


			
quizapp cmd setup-test-db
0.0%
Statements
0/591
main.go
0.0%
0/591
quizapp cmd setup-test-db main.go
0.0%
Statements
0/591
1
// Package main provides a utility to set up the test database with initial data.
2
package main
3

4
import (
5
    "context"
6
    "database/sql"
7
    "encoding/json"
8
    "flag"
9
    "fmt"
10
    "os"
11
    "path/filepath"
12
    "strings"
13
    "time"
14

15
    "quizapp/internal/api"
16
    "quizapp/internal/config"
17
    "quizapp/internal/database"
18
    "quizapp/internal/models"
19
    "quizapp/internal/observability"
20
    "quizapp/internal/services"
21
    contextutils "quizapp/internal/utils"
22

23
    "go.uber.org/zap/zapcore"
24
    "gopkg.in/yaml.v3"
25
)
26

27
// TestUser represents a user in the test data files
28
type TestUser struct {
29
    Username          string   `yaml:"username"`
30
    Email             string   `yaml:"email"`
31
    Password          string   `yaml:"password"` // Special field for password creation
32
    PreferredLanguage string   `yaml:"preferred_language"`
33
    CurrentLevel      string   `yaml:"current_level"`
34
    AIProvider        string   `yaml:"ai_provider"`
35
    AIModel           string   `yaml:"ai_model"`
36
    AIAPIKey          string   `yaml:"ai_api_key"`
37
    Roles             []string `yaml:"roles"`
38
}
39

40
// TestUsers represents a collection of test users
41
type TestUsers struct {
42
    Users []TestUser `yaml:"users"`
43
}
44

45
// TestQuestions represents a collection of test questions
46
type TestQuestions struct {
47
    Questions []models.Question `yaml:"questions"`
48
}
49

50
// TestResponses represents a collection of test user responses
51
type TestResponses struct {
52
    UserResponses []struct {
53
        Username       string `yaml:"username"`
54
        QuestionIndex  int    `yaml:"question_index"`
55
        UserAnswer     string `yaml:"user_answer"`
56
        IsCorrect      bool   `yaml:"is_correct"`
57
        ResponseTimeMs int    `yaml:"response_time_ms"`
58
    } `yaml:"user_responses"`
59

60
    QuestionReports []struct {
61
        Username      string  `yaml:"username"`
62
        QuestionIndex int     `yaml:"question_index"`
63
        ReportReason  string  `yaml:"report_reason"`
64
        CreatedAt     *string `yaml:"created_at"`
65
    } `yaml:"question_reports"`
66
}
67

68
// TestAnalytics represents analytics test data
69
type TestAnalytics struct {
70
    PriorityScores []struct {
71
        Username         string  `yaml:"username"`
72
        QuestionIndex    int     `yaml:"question_index"`
73
        PriorityScore    float64 `yaml:"priority_score"`
74
        LastCalculatedAt string  `yaml:"last_calculated_at"`
75
    } `yaml:"priority_scores"`
76

77
    LearningPreferences []struct {
78
        Username             string  `yaml:"username"`
79
        FocusOnWeakAreas     bool    `yaml:"focus_on_weak_areas"`
80
        FreshQuestionRatio   float64 `yaml:"fresh_question_ratio"`
81
        WeakAreaBoost        float64 `yaml:"weak_area_boost"`
82
        KnownQuestionPenalty float64 `yaml:"known_question_penalty"`
83
        ReviewIntervalDays   int     `yaml:"review_interval_days"`
84
        DailyReminderEnabled bool    `yaml:"daily_reminder_enabled"`
85
    } `yaml:"learning_preferences"`
86

87
    PerformanceMetrics []struct {
88
        Username              string  `yaml:"username"`
89
        Topic                 string  `yaml:"topic"`
90
        Language              string  `yaml:"language"`
91
        Level                 string  `yaml:"level"`
92
        TotalAttempts         int     `yaml:"total_attempts"`
93
        CorrectAttempts       int     `yaml:"correct_attempts"`
94
        AverageResponseTimeMs float64 `yaml:"average_response_time_ms"`
95
    } `yaml:"performance_metrics"`
96

97
    UserQuestionMetadata []struct {
98
        Username        string  `yaml:"username"`
99
        QuestionIndex   int     `yaml:"question_index"`
100
        MarkedAsKnown   bool    `yaml:"marked_as_known"`
101
        MarkedAsKnownAt *string `yaml:"marked_as_known_at"`
102
    } `yaml:"user_question_metadata"`
103
}
104

105
// TestDailyAssignments represents the structure for daily question assignments in test data
106
type TestDailyAssignments struct {
107
    DailyAssignments []struct {
108
        Username           string `yaml:"username"`
109
        Date               string `yaml:"date"`
110
        QuestionIDs        []int  `yaml:"question_ids"`
111
        CompletedQuestions []int  `yaml:"completed_questions"`
112
    } `yaml:"daily_assignments"`
113
}
114

115
// TestMessageData represents message data for E2E tests
116
type TestMessageData struct {
117
    ID             string `json:"id"`
118
    ConversationID string `json:"conversation_id"`
119
    Role           string `json:"role"`
120
    Content        string `json:"content"`
121
    Bookmarked     bool   `json:"bookmarked"`
122
    QuestionID     *int   `json:"question_id,omitempty"`
123
    CreatedAt      string `json:"created_at"`
124
    UpdatedAt      string `json:"updated_at"`
125
}
126

127
// TestConversationData represents conversation data for E2E tests
128
type TestConversationData struct {
129
    ID       string            `json:"id"`
130
    Username string            `json:"username"`
131
    Title    string            `json:"title"`
132
    Messages []TestMessageData `json:"messages"`
133
}
134

135
// TestConversations represents a collection of test conversations
136
type TestConversations struct {
137
    Conversations []struct {
138
        Username string `yaml:"username"`
139
        Title    string `yaml:"title"`
140
        Messages []struct {
141
            Role       string `yaml:"role"`
142
            Content    string `yaml:"content"`
143
            QuestionID *int   `yaml:"question_id"`
144
        } `yaml:"messages"`
145
    } `yaml:"conversations"`
146
}
147

148
// TestStorySectionData represents section data for E2E tests
149
type TestStorySectionData struct {
150
    ID            int    `json:"id"`
151
    StoryID       int    `json:"story_id"`
152
    SectionNumber int    `json:"section_number"`
153
    Content       string `json:"content"`
154
    LanguageLevel string `json:"language_level"`
155
    WordCount     int    `json:"word_count"`
156
    GeneratedBy   string `json:"generated_by"`
157
}
158

159
// TestStoryData represents story data for E2E tests
160
type TestStoryData struct {
161
    ID       int                    `json:"id"`
162
    Username string                 `json:"username"`
163
    Title    string                 `json:"title"`
164
    Status   string                 `json:"status"`
165
    Sections []TestStorySectionData `json:"sections"`
166
}
167

168
// TestStories represents a collection of test stories
169
type TestStories struct {
170
    Stories []struct {
171
        Username              string  `yaml:"username"`
172
        Title                 string  `yaml:"title"`
173
        Language              string  `yaml:"language"`
174
        Subject               *string `yaml:"subject"`
175
        AuthorStyle           *string `yaml:"author_style"`
176
        TimePeriod            *string `yaml:"time_period"`
177
        Genre                 *string `yaml:"genre"`
178
        Tone                  *string `yaml:"tone"`
179
        CharacterNames        *string `yaml:"character_names"`
180
        CustomInstructions    *string `yaml:"custom_instructions"`
181
        SectionLengthOverride *string `yaml:"section_length_override"`
182
        Status                string  `yaml:"status"`
183
        IsCurrent             bool    `yaml:"is_current"`
184
        Sections              []struct {
185
            SectionNumber int    `yaml:"section_number"`
186
            Content       string `yaml:"content"`
187
            LanguageLevel string `yaml:"language_level"`
188
            WordCount     int    `yaml:"word_count"`
189
            GeneratedBy   string `yaml:"generated_by"`
190
            Questions     []struct {
191
                QuestionText       string   `yaml:"question_text"`
192
                Options            []string `yaml:"options"`
193
                CorrectAnswerIndex int      `yaml:"correct_answer_index"`
194
                Explanation        *string  `yaml:"explanation"`
195
            } `yaml:"questions"`
196
        } `yaml:"sections"`
197
    } `yaml:"stories"`
198
}
199

200
// TestSnippetData represents snippet data for E2E tests
201
type TestSnippetData struct {
202
    ID             int    `json:"id"`
203
    Username       string `json:"username"`
204
    OriginalText   string `json:"original_text"`
205
    TranslatedText string `json:"translated_text"`
206
    SourceLanguage string `json:"source_language"`
207
    TargetLanguage string `json:"target_language"`
208
}
209

210
// TestSnippets represents a collection of test snippets
211
type TestSnippets struct {
212
    Snippets []struct {
213
        Username        string  `yaml:"username"`
214
        OriginalText    string  `yaml:"original_text"`
215
        TranslatedText  string  `yaml:"translated_text"`
216
        SourceLanguage  string  `yaml:"source_language"`
217
        TargetLanguage  string  `yaml:"target_language"`
218
        Context         *string `yaml:"context"`
219
        DifficultyLevel string  `yaml:"difficulty_level"`
220
    } `yaml:"snippets"`
221
}
222

223
// TestFeedbackData represents feedback data for E2E tests
224
type TestFeedbackData struct {
225
    ID           int                    `json:"id"`
226
    Username     string                 `json:"username"`
227
    FeedbackText string                 `json:"feedback_text"`
228
    FeedbackType string                 `json:"feedback_type"`
229
    Status       string                 `json:"status"`
230
    ContextData  map[string]interface{} `json:"context_data"`
231
}
232

233
// TestFeedback represents a collection of test feedback
234
type TestFeedback struct {
235
    FeedbackReports []struct {
236
        Username     string                 `yaml:"username"`
237
        FeedbackText string                 `yaml:"feedback_text"`
238
        FeedbackType string                 `yaml:"feedback_type"`
239
        Status       string                 `yaml:"status"`
240
        ContextData  map[string]interface{} `yaml:"context_data"`
241
    } `yaml:"feedback_reports"`
242
}
243

244
func resetTestDatabase(databaseURL, testDB string, logger *observability.Logger) error {
245
    ctx := context.Background()
246

247
    // Create admin connection string by replacing the database name with 'postgres'
248
    // This connects to the admin database to drop/create the test database
249
    adminConnStr := strings.Replace(databaseURL, "/"+testDB+"?", "/postgres?", 1)
250
    if !strings.Contains(adminConnStr, "/postgres?") {
251
        // Handle case where there's no query string
252
        adminConnStr = strings.Replace(databaseURL, "/"+testDB, "/postgres", 1)
253
    }
254

255
    logger.Info(ctx, "Connecting to admin database", map[string]interface{}{"connection_string": adminConnStr})
256
    adminDB, err := sql.Open("postgres", adminConnStr)
257
    if err != nil {
258
        return contextutils.WrapErrorf(contextutils.ErrDatabaseConnection, "failed to connect to postgres database for drop/create: %v", err)
259
    }
260
    defer func() {
261
        if err := adminDB.Close(); err != nil {
262
            logger.Warn(ctx, "Warning: failed to close adminDB", map[string]interface{}{"error": err.Error()})
263
        }
264
    }()
265

266
    logger.Info(ctx, "Terminating connections to test DB", map[string]interface{}{"database": testDB})
267
    _, err = adminDB.Exec(fmt.Sprintf(`
268
        SELECT pg_terminate_backend(pid)
269
        FROM pg_stat_activity
270
        WHERE datname = '%s' AND pid <> pg_backend_pid();
271
    `, testDB))
272
    if err != nil {
273
        logger.Warn(ctx, "Warning: failed to terminate connections", map[string]interface{}{"error": err.Error()})
274
    }
275

276
    logger.Info(ctx, "Dropping test database", map[string]interface{}{"database": testDB})
277
    _, err = adminDB.Exec(fmt.Sprintf("DROP DATABASE IF EXISTS %s WITH (FORCE);", testDB))
278
    if err != nil {
279
        return contextutils.WrapErrorf(contextutils.ErrDatabaseQuery, "failed to drop test database: %v", err)
280
    }
281
    logger.Info(ctx, "Successfully dropped test database", map[string]interface{}{"database": testDB})
282

283
    logger.Info(ctx, "Creating test database", map[string]interface{}{"database": testDB})
284
    _, err = adminDB.Exec(fmt.Sprintf("CREATE DATABASE %s;", testDB))
285
    if err != nil {
286
        return contextutils.WrapErrorf(contextutils.ErrDatabaseQuery, "failed to create test database: %v", err)
287
    }
288
    logger.Info(ctx, "Successfully created test database", map[string]interface{}{"database": testDB})
289

290
    logger.Info(ctx, "Test database reset complete")
291
    return nil
292
}
293

294
func main() {
295
    ctx := context.Background()
296

297
    // CLI flags
298
    verbose := flag.Bool("verbose", false, "enable verbose logging")
299
    flag.Parse()
300

301
    // Load configuration first
302
    cfg, err := config.NewConfig()
303
    if err != nil {
304
        fmt.Fprintf(os.Stderr, "Failed to load config: %v\n", err)
305
        os.Exit(1)
306
    }
307

308
    // Setup observability (tracing/metrics). Suppress logger creation here to avoid startup noise.
309
    originalLogging := cfg.OpenTelemetry.EnableLogging
310
    cfg.OpenTelemetry.EnableLogging = false
311
    tp, mp, _, err := observability.SetupObservability(&cfg.OpenTelemetry, "setup-test-db")
312
    if err != nil {
313
        fmt.Fprintf(os.Stderr, "Failed to initialize observability: %v\n", err)
314
        os.Exit(1)
315
    }
316

317
    // Create logger with level based on --verbose flag
318
    logLevel := zapcore.WarnLevel
319
    if *verbose {
320
        logLevel = zapcore.InfoLevel
321
    }
322
    // Restore config flag for logger construction (to allow OTLP exporter if enabled)
323
    cfg.OpenTelemetry.EnableLogging = originalLogging
324
    logger := observability.NewLoggerWithLevel(&cfg.OpenTelemetry, logLevel)
325
    defer func() {
326
        if tp != nil {
327
            if err := tp.Shutdown(context.TODO()); err != nil {
328
                logger.Warn(ctx, "Error shutting down tracer provider", map[string]interface{}{"error": err.Error()})
329
            }
330
        }
331
        if mp != nil {
332
            if err := mp.Shutdown(context.TODO()); err != nil {
333
                logger.Warn(ctx, "Error shutting down meter provider", map[string]interface{}{"error": err.Error()})
334
            }
335
        }
336
    }()
337

338
    // Get DB connection info from env or use defaults
339
    dbUser := "quiz_user"
340
    dbPassword := "quiz_password"
341
    dbHost := "localhost"
342
    dbPort := "5433"
343
    testDB := "quiz_test_db"
344

345
    // Allow override from DATABASE_URL
346
    databaseURL := os.Getenv("DATABASE_URL")
347
    if databaseURL == "" {
348
        databaseURL = fmt.Sprintf("postgres://%s:%s@%s:%s/%s?sslmode=disable", dbUser, dbPassword, dbHost, dbPort, testDB)
349
    }
350

351
    // Debug: Print the DATABASE_URL we're using
352
    logger.Info(ctx, "DATABASE_URL from environment", map[string]interface{}{"database_url": os.Getenv("DATABASE_URL")})
353
    logger.Info(ctx, "Using database URL", map[string]interface{}{"database_url": databaseURL})
354

355
    // --- Drop and recreate the test database ---
356
    if err := resetTestDatabase(databaseURL, testDB, logger); err != nil {
357
        logger.Error(ctx, "Failed to reset test database", err)
358
        os.Exit(1)
359
    }
360

361
    // Now connect to the new test database
362
    logger.Info(ctx, "Connecting to database", map[string]interface{}{"database_url": databaseURL})
363

364
    // Initialize database manager with logger
365
    dbManager := database.NewManager(logger)
366
    db, err := dbManager.InitDB(databaseURL)
367
    if err != nil {
368
        logger.Error(ctx, "Failed to initialize database", err)
369
        os.Exit(1)
370
    }
371
    defer func() {
372
        if err := db.Close(); err != nil {
373
            logger.Warn(ctx, "Warning: failed to close database", map[string]interface{}{"error": err.Error()})
374
        }
375
    }()
376

377
    // Get the root directory (backend is the working directory)
378
    rootDir, err := os.Getwd()
379
    if err != nil {
380
        logger.Error(ctx, "Failed to get working directory", err)
381
        os.Exit(1)
382
    }
383

384
    // Apply schema from schema.sql
385
    schemaPath := filepath.Join(rootDir, "..", "schema.sql")
386
    if err := applySchema(db, schemaPath, rootDir, logger); err != nil {
387
        logger.Error(ctx, "Failed to apply schema", err)
388
        os.Exit(1)
389
    }
390

391
    // Initialize services
392
    userService := services.NewUserServiceWithLogger(db, cfg, logger)
393
    learningService := services.NewLearningServiceWithLogger(db, cfg, logger)
394
    // Create question service
395
    questionService := services.NewQuestionServiceWithLogger(db, learningService, cfg, logger)
396

397
    // Ensure admin user exists
398
    if err := userService.EnsureAdminUserExists(ctx, "admin", "password"); err != nil {
399
        logger.Error(ctx, "Failed to ensure admin user exists", err)
400
        os.Exit(1)
401
    }
402

403
    // Load and insert test data
404
    users, err := setupTestData(ctx, rootDir, userService, questionService, learningService, db, logger)
405
    if err != nil {
406
        logger.Error(ctx, "Failed to setup test data", err)
407
        os.Exit(1)
408
    }
409

410
    // Output user data to JSON file for E2E tests
411
    if err := outputUserDataForTests(users, rootDir, logger); err != nil {
412
        logger.Error(ctx, "Failed to output user data for tests", err)
413
        os.Exit(1)
414
    }
415

416
    // Output roles data to JSON file for E2E tests
417
    if err := outputRolesDataForTests(db, rootDir, logger); err != nil {
418
        logger.Error(ctx, "Failed to output roles data for tests", err)
419
        os.Exit(1)
420
    }
421

422
    logger.Info(ctx, "Test database created successfully")
423
}
424

425
func applySchema(db *sql.DB, schemaPath, _ string, logger *observability.Logger) error {
426
    ctx := context.Background()
427

428
    // Apply the schema (database is already empty after resetTestDatabase)
429
    logger.Info(ctx, "Applying schema")
430
    schemaSQL, err := os.ReadFile(schemaPath)
431
    if err != nil {
432
        return contextutils.WrapErrorf(contextutils.ErrDatabaseQuery, "failed to read schema file: %v", err)
433
    }
434

435
    if _, err := db.Exec(string(schemaSQL)); err != nil {
436
        return contextutils.WrapErrorf(contextutils.ErrDatabaseQuery, "failed to execute schema: %v", err)
437
    }
438

439
    // Priority system tables are already included in the main schema.sql
440
    // No additional migration needed
441
    logger.Info(ctx, "Priority system tables already included in main schema")
442

443
    return nil
444
}
445

446
func setupTestData(ctx context.Context, rootDir string, userService *services.UserService, questionService *services.QuestionService, learningService *services.LearningService, db *sql.DB, logger *observability.Logger) (map[string]*models.User, error) {
447
    dataDir := filepath.Join(rootDir, "data")
448

449
    // 1. Load and create users
450
    users, err := loadAndCreateUsers(ctx, filepath.Join(dataDir, "test_users.yaml"), userService, logger)
451
    if err != nil {
452
        return nil, contextutils.WrapErrorf(contextutils.ErrDatabaseQuery, "failed to setup users: %v", err)
453
    }
454

455
    // 2. Load and create questions
456
    questions, err := loadAndCreateQuestions(ctx, filepath.Join(dataDir, "test_questions.yaml"), questionService, users, logger)
457
    if err != nil {
458
        return nil, contextutils.WrapErrorf(contextutils.ErrDatabaseQuery, "failed to setup questions: %v", err)
459
    }
460

461
    // 3. Load and create user responses
462
    if err := loadAndCreateResponses(ctx, filepath.Join(dataDir, "test_responses.yaml"), users, questions, learningService, logger); err != nil {
463
        return nil, contextutils.WrapErrorf(contextutils.ErrDatabaseQuery, "failed to setup responses: %v", err)
464
    }
465

466
    // 4. Load and create question reports
467
    if err := loadAndCreateQuestionReports(ctx, filepath.Join(dataDir, "test_responses.yaml"), users, questions, db, logger); err != nil {
468
        return nil, contextutils.WrapErrorf(contextutils.ErrDatabaseQuery, "failed to setup question reports: %v", err)
469
    }
470

471
    // 5. Load and create analytics data
472
    if err := loadAndCreateAnalytics(ctx, filepath.Join(dataDir, "test_analytics.yaml"), users, questions, learningService, db, logger); err != nil {
473
        return nil, contextutils.WrapErrorf(contextutils.ErrDatabaseQuery, "failed to setup analytics: %v", err)
474
    }
475

476
    // 6. Load and create daily assignments
477
    if err := loadAndCreateDailyAssignments(ctx, filepath.Join(dataDir, "test_daily_assignments.yaml"), users, questions, db, logger); err != nil {
478
        return nil, contextutils.WrapErrorf(contextutils.ErrDatabaseQuery, "failed to setup daily assignments: %v", err)
479
    }
480

481
    // 7. Load and create stories
482
    stories, err := loadAndCreateStories(ctx, filepath.Join(dataDir, "test_stories.yaml"), users, db, logger)
483
    if err != nil {
484
        return nil, contextutils.WrapErrorf(contextutils.ErrDatabaseQuery, "failed to setup stories: %v", err)
485
    }
486

487
    // Output story data for E2E tests
488
    if err := outputStoryDataForTests(stories, rootDir, logger); err != nil {
489
        return nil, contextutils.WrapErrorf(contextutils.ErrDatabaseQuery, "failed to output story data: %v", err)
490
    }
491

492
    // 8. Load and create snippets
493
    snippets, err := loadAndCreateSnippets(ctx, filepath.Join(dataDir, "test_snippets.yaml"), users, db, logger)
494
    if err != nil {
495
        return nil, contextutils.WrapErrorf(contextutils.ErrDatabaseQuery, "failed to setup snippets: %v", err)
496
    }
497

498
    // Output snippet data for E2E tests
499
    if err := outputSnippetDataForTests(snippets, rootDir, logger); err != nil {
500
        return nil, contextutils.WrapErrorf(contextutils.ErrDatabaseQuery, "failed to output snippet data: %v", err)
501
    }
502

503
    // 9. Load and create conversations
504
    conversations, err := loadAndCreateConversations(ctx, filepath.Join(dataDir, "test_conversations.yaml"), users, db, logger)
505
    if err != nil {
506
        return nil, contextutils.WrapErrorf(contextutils.ErrDatabaseQuery, "failed to setup conversations: %v", err)
507
    }
508

509
    // Output conversation data for E2E tests
510
    if err := outputConversationDataForTests(conversations, rootDir, logger); err != nil {
511
        return nil, contextutils.WrapErrorf(contextutils.ErrDatabaseQuery, "failed to output conversation data: %v", err)
512
    }
513

514
    // 10. Load and create feedback reports
515
    feedback, err := loadAndCreateFeedback(ctx, filepath.Join(dataDir, "test_feedback.yaml"), users, db, logger)
516
    if err != nil {
517
        return nil, contextutils.WrapErrorf(contextutils.ErrDatabaseQuery, "failed to setup feedback: %v", err)
518
    }
519

520
    // Output feedback data for E2E tests
521
    if err := outputFeedbackDataForTests(feedback, rootDir, logger); err != nil {
522
        return nil, contextutils.WrapErrorf(contextutils.ErrDatabaseQuery, "failed to output feedback data: %v", err)
523
    }
524

525
    // 11. Create API Keys for test users
526
    if err := createAndOutputAPIKeysForTests(ctx, users, db, rootDir, logger); err != nil {
527
        return nil, contextutils.WrapErrorf(contextutils.ErrDatabaseQuery, "failed to setup api keys: %v", err)
528
    }
529

530
    return users, nil
531
}
532

533
// TestAPIKeyData represents API key data for E2E tests (non-sensitive)
534
type TestAPIKeyData struct {
535
    ID              int       `json:"id"`
536
    Username        string    `json:"username"`
537
    KeyName         string    `json:"key_name"`
538
    KeyPrefix       string    `json:"key_prefix"`
539
    PermissionLevel string    `json:"permission_level"`
540
    CreatedAt       time.Time `json:"created_at"`
541
}
542

543
// createAndOutputAPIKeysForTests creates API keys for selected users and writes a JSON artifact for tests
544
func createAndOutputAPIKeysForTests(ctx context.Context, users map[string]*models.User, db *sql.DB, rootDir string, logger *observability.Logger) error {
545
    // Initialize service
546
    apiKeyService := services.NewAuthAPIKeyService(db, logger)
547

548
    // Strategy:
549
    // - apitestuser: 2 keys (readonly, full)
550
    // - apitestadmin: 2 keys (readonly, full)
551
    // - others: 1 readonly key
552

553
    // Helper to create a key and capture minimal info
554
    create := func(username string, userID int, keyName, perm string) (*TestAPIKeyData, error) {
555
        key, _, err := apiKeyService.CreateAPIKey(ctx, userID, keyName, perm)
556
        if err != nil {
557
            return nil, err
558
        }
559
        return &TestAPIKeyData{
560
            ID:              key.ID,
561
            Username:        username,
562
            KeyName:         key.KeyName,
563
            KeyPrefix:       key.KeyPrefix,
564
            PermissionLevel: key.PermissionLevel,
565
            CreatedAt:       key.CreatedAt,
566
        }, nil
567
    }
568

569
    apiKeys := make(map[string]TestAPIKeyData)
570

571
    for username, user := range users {
572
        if username == "apitestuser" || username == "apitestadmin" {
573
            if d, err := create(username, user.ID, "test_key_readonly", string(models.PermissionLevelReadonly)); err == nil {
574
                apiKeys[fmt.Sprintf("%s_ro", username)] = *d
575
            } else {
576
                return contextutils.WrapErrorf(err, "failed creating readonly api key for %s", username)
577
            }
578
            if d, err := create(username, user.ID, "test_key_full", string(models.PermissionLevelFull)); err == nil {
579
                apiKeys[fmt.Sprintf("%s_full", username)] = *d
580
            } else {
581
                return contextutils.WrapErrorf(err, "failed creating full api key for %s", username)
582
            }
583
        } else {
584
            if d, err := create(username, user.ID, "test_key_readonly", string(models.PermissionLevelReadonly)); err == nil {
585
                apiKeys[fmt.Sprintf("%s_ro", username)] = *d
586
            } else {
587
                return contextutils.WrapErrorf(err, "failed creating readonly api key for %s", username)
588
            }
589
        }
590
    }
591

592
    // Write to JSON file in the frontend/tests directory
593
    outputPath := filepath.Join(rootDir, "..", "frontend", "tests", "test-api-keys.json")
594
    outputDir := filepath.Dir(outputPath)
595
    if err := os.MkdirAll(outputDir, 0o755); err != nil {
596
        return contextutils.WrapErrorf(err, "failed to create output directory: %s", outputDir)
597
    }
598
    jsonData, err := json.MarshalIndent(apiKeys, "", "  ")
599
    if err != nil {
600
        return contextutils.WrapErrorf(err, "failed to marshal api keys data to JSON")
601
    }
602
    if err := os.WriteFile(outputPath, jsonData, 0o644); err != nil {
603
        return contextutils.WrapErrorf(err, "failed to write api keys data to file: %s", outputPath)
604
    }
605

606
    logger.Info(context.Background(), "Output API keys data for E2E tests", map[string]interface{}{
607
        "file_path":  outputPath,
608
        "keys_count": len(apiKeys),
609
    })
610

611
    return nil
612
}
613

614
func loadAndCreateUsers(ctx context.Context, filePath string, userService *services.UserService, logger *observability.Logger) (result0 map[string]*models.User, err error) {
615
    data, err := os.ReadFile(filePath)
616
    if err != nil {
617
        return nil, err
618
    }
619

620
    var testUsers TestUsers
621
    if err := yaml.Unmarshal(data, &testUsers); err != nil {
622
        return nil, err
623
    }
624

625
    users := make(map[string]*models.User)
626
    for _, testUser := range testUsers.Users {
627
        // Create user with email and timezone
628
        user, err := userService.CreateUserWithEmailAndTimezone(
629
            ctx,
630
            testUser.Username,
631
            testUser.Email,
632
            "UTC", // Default timezone for test users
633
            testUser.PreferredLanguage,
634
            testUser.CurrentLevel,
635
        )
636
        if err != nil {
637
            return nil, contextutils.WrapErrorf(err, "failed to create user %s", testUser.Username)
638
        }
639

640
        // Set password separately since CreateUserWithEmailAndTimezone doesn't set password
641
        if err := userService.UpdateUserPassword(ctx, user.ID, testUser.Password); err != nil {
642
            return nil, contextutils.WrapErrorf(err, "failed to set password for user %s", testUser.Username)
643
        }
644

645
        // Update additional settings
646
        settings := &models.UserSettings{
647
            Language:   testUser.PreferredLanguage,
648
            Level:      testUser.CurrentLevel,
649
            AIProvider: testUser.AIProvider,
650
            AIModel:    testUser.AIModel,
651
            AIAPIKey:   testUser.AIAPIKey,
652
            AIEnabled:  testUser.AIProvider != "", // Enable AI if provider is set
653
        }
654

655
        if err := userService.UpdateUserSettings(ctx, user.ID, settings); err != nil {
656
            return nil, contextutils.WrapErrorf(err, "failed to update settings for user %s", testUser.Username)
657
        }
658

659
        // Assign roles from YAML configuration
660
        for _, roleName := range testUser.Roles {
661
            err = userService.AssignRoleByName(ctx, user.ID, roleName)
662
            if err != nil {
663
                logger.Warn(ctx, "Failed to assign role to user", map[string]interface{}{
664
                    "username": testUser.Username,
665
                    "role":     roleName,
666
                    "error":    err.Error(),
667
                })
668
            } else {
669
                logger.Info(ctx, "Assigned role to user", map[string]interface{}{
670
                    "username": testUser.Username,
671
                    "role":     roleName,
672
                    "user_id":  user.ID,
673
                })
674
            }
675
        }
676

677
        users[testUser.Username] = user
678
    }
679

680
    return users, nil
681
}
682

683
func loadAndCreateQuestions(ctx context.Context, filePath string, questionService *services.QuestionService, users map[string]*models.User, _ *observability.Logger) (result0 []*models.Question, err error) {
684
    data, err := os.ReadFile(filePath)
685
    if err != nil {
686
        return nil, err
687
    }
688

689
    var testQuestions TestQuestions
690
    if err := yaml.Unmarshal(data, &testQuestions); err != nil {
691
        return nil, err
692
    }
693

694
    var questions []*models.Question
695
    for i, question := range testQuestions.Questions {
696
        // Set the created time since it's not in YAML
697
        question.CreatedAt = time.Now()
698

699
        // Get the users this question should be assigned to
700
        questionUsers := question.Users
701
        var assignedUserIDs []int
702
        if len(questionUsers) == 0 {
703
            // Fallback to round-robin if no users specified
704
            for _, user := range users {
705
                assignedUserIDs = append(assignedUserIDs, user.ID)
706
            }
707
            if len(assignedUserIDs) == 0 {
708
                return nil, contextutils.ErrorWithContextf("no users available to assign questions to")
709
            }
710
            // Assign to one user in round-robin
711
            assignedUserIDs = []int{assignedUserIDs[i%len(assignedUserIDs)]}
712
        } else {
713
            for _, username := range questionUsers {
714
                user, exists := users[username]
715
                if !exists {
716
                    return nil, contextutils.ErrorWithContextf("user not found: %s", username)
717
                }
718
                assignedUserIDs = append(assignedUserIDs, user.ID)
719
            }
720
        }
721

722
        if err := questionService.SaveQuestion(ctx, &question); err != nil {
723
            return nil, contextutils.WrapErrorf(err, "failed to save question %d", i)
724
        }
725

726
        for _, userID := range assignedUserIDs {
727
            if err := questionService.AssignQuestionToUser(ctx, question.ID, userID); err != nil {
728
                return nil, contextutils.WrapErrorf(err, "failed to assign question %d to user %d", question.ID, userID)
729
            }
730
        }
731

732
        questions = append(questions, &question)
733
    }
734

735
    return questions, nil
736
}
737

738
func loadAndCreateResponses(_ context.Context, filePath string, users map[string]*models.User, questions []*models.Question, learningService *services.LearningService, _ *observability.Logger) error {
739
    data, err := os.ReadFile(filePath)
740
    if err != nil {
741
        return err
742
    }
743

744
    var testResponses TestResponses
745
    if err := yaml.Unmarshal(data, &testResponses); err != nil {
746
        return err
747
    }
748

749
    for i, responseData := range testResponses.UserResponses {
750
        user, exists := users[responseData.Username]
751
        if !exists {
752
            return contextutils.ErrorWithContextf("user not found: %s", responseData.Username)
753
        }
754

755
        if responseData.QuestionIndex >= len(questions) {
756
            return contextutils.ErrorWithContextf("question index out of range: %d", responseData.QuestionIndex)
757
        }
758

759
        question := questions[responseData.QuestionIndex]
760

761
        // Use RecordAnswerWithPriority to ensure priority scores are calculated
762
        if err := learningService.RecordAnswerWithPriority(
763
            context.Background(),
764
            user.ID,
765
            question.ID,
766
            0, // Use index 0 for test data
767
            responseData.IsCorrect,
768
            responseData.ResponseTimeMs,
769
        ); err != nil {
770
            return contextutils.WrapErrorf(err, "failed to record response %d", i)
771
        }
772

773
    }
774

775
    return nil
776
}
777

778
func loadAndCreateQuestionReports(_ context.Context, filePath string, users map[string]*models.User, questions []*models.Question, db *sql.DB, _ *observability.Logger) error {
779
    data, err := os.ReadFile(filePath)
780
    if err != nil {
781
        return contextutils.WrapError(err, "failed to read responses file")
782
    }
783

784
    var testResponses TestResponses
785
    if err := yaml.Unmarshal(data, &testResponses); err != nil {
786
        return contextutils.WrapError(err, "failed to parse responses data")
787
    }
788

789
    // Load question reports
790
    for i, reportData := range testResponses.QuestionReports {
791
        user, exists := users[reportData.Username]
792
        if !exists {
793
            return contextutils.ErrorWithContextf("user not found for question report: %s", reportData.Username)
794
        }
795

796
        if reportData.QuestionIndex >= len(questions) {
797
            return contextutils.ErrorWithContextf("question index out of range for question report: %d", reportData.QuestionIndex)
798
        }
799

800
        question := questions[reportData.QuestionIndex]
801

802
        // Parse the timestamp if provided, otherwise use current time
803
        var createdAt time.Time
804
        if reportData.CreatedAt != nil {
805
            var err error
806
            createdAt, err = time.Parse(time.RFC3339, *reportData.CreatedAt)
807
            if err != nil {
808
                return contextutils.ErrorWithContextf("invalid timestamp format for question report: %s", *reportData.CreatedAt)
809
            }
810
        } else {
811
            createdAt = time.Now()
812
        }
813

814
        // Insert question report directly into database
815
        _, err := db.Exec(`
816
            INSERT INTO question_reports (question_id, reported_by_user_id, report_reason, created_at)
817
            VALUES ($1, $2, $3, $4)
818
            ON CONFLICT (question_id, reported_by_user_id) DO NOTHING
819
        `, question.ID, user.ID, reportData.ReportReason, createdAt)
820
        if err != nil {
821
            return contextutils.WrapErrorf(err, "failed to insert question report %d", i)
822
        }
823
    }
824

825
    return nil
826
}
827

828
func loadAndCreateAnalytics(ctx context.Context, filePath string, users map[string]*models.User, questions []*models.Question, learningService *services.LearningService, db *sql.DB, logger *observability.Logger) error {
829
    data, err := os.ReadFile(filePath)
830
    if err != nil {
831
        // Analytics file is optional, so just return if it doesn't exist
832
        logger.Warn(ctx, "Analytics file not found", map[string]interface{}{"file_path": filePath})
833
        return nil
834
    }
835

836
    var testAnalytics TestAnalytics
837
    if err := yaml.Unmarshal(data, &testAnalytics); err != nil {
838
        return contextutils.WrapError(err, "failed to parse analytics data")
839
    }
840

841
    // Load priority scores
842
    for _, priorityData := range testAnalytics.PriorityScores {
843
        user, exists := users[priorityData.Username]
844
        if !exists {
845
            return contextutils.ErrorWithContextf("user not found for priority score: %s", priorityData.Username)
846
        }
847

848
        if priorityData.QuestionIndex >= len(questions) {
849
            return contextutils.ErrorWithContextf("question index out of range for priority score: %d", priorityData.QuestionIndex)
850
        }
851

852
        question := questions[priorityData.QuestionIndex]
853

854
        // Parse the timestamp
855
        lastCalculatedAt, err := time.Parse(time.RFC3339, priorityData.LastCalculatedAt)
856
        if err != nil {
857
            return contextutils.ErrorWithContextf("invalid timestamp format for priority score: %s", priorityData.LastCalculatedAt)
858
        }
859

860
        // Insert priority score directly into database
861
        _, err = db.Exec(`
862
            INSERT INTO question_priority_scores (user_id, question_id, priority_score, last_calculated_at, created_at, updated_at)
863
            VALUES ($1, $2, $3, $4, NOW(), NOW())
864
            ON CONFLICT (user_id, question_id) DO UPDATE SET
865
                priority_score = EXCLUDED.priority_score,
866
                last_calculated_at = EXCLUDED.last_calculated_at,
867
                updated_at = NOW()
868
        `, user.ID, question.ID, priorityData.PriorityScore, lastCalculatedAt)
869
        if err != nil {
870
            return contextutils.WrapError(err, "failed to insert priority score")
871
        }
872

873
    }
874

875
    // Load learning preferences
876
    for _, prefData := range testAnalytics.LearningPreferences {
877
        user, exists := users[prefData.Username]
878
        if !exists {
879
            return contextutils.ErrorWithContextf("user not found for learning preferences: %s", prefData.Username)
880
        }
881

882
        // Ensure daily_goal is present and valid. The schema enforces daily_goal > 0
883
        // so default to the service's default if not provided or invalid.
884
        dailyGoal := 0
885
        // Try to parse a daily_goal field if it exists in the YAML by checking for a map
886
        // fallback: the YAML struct doesn't include daily_goal currently; use default
887
        // from the LearningService defaults.
888
        // We'll fetch defaults from service to avoid duplicating magic numbers.
889
        defaultPrefs := learningService.GetDefaultLearningPreferences()
890
        if dailyGoal <= 0 {
891
            dailyGoal = defaultPrefs.DailyGoal
892
        }
893

894
        prefs := &models.UserLearningPreferences{
895
            UserID:               user.ID,
896
            FocusOnWeakAreas:     prefData.FocusOnWeakAreas,
897
            FreshQuestionRatio:   prefData.FreshQuestionRatio,
898
            WeakAreaBoost:        prefData.WeakAreaBoost,
899
            KnownQuestionPenalty: prefData.KnownQuestionPenalty,
900
            ReviewIntervalDays:   prefData.ReviewIntervalDays,
901
            DailyReminderEnabled: prefData.DailyReminderEnabled,
902
            DailyGoal:            dailyGoal,
903
        }
904

905
        if _, err := learningService.UpdateUserLearningPreferences(ctx, user.ID, prefs); err != nil {
906
            return contextutils.WrapErrorf(err, "failed to update learning preferences for user %s", prefData.Username)
907
        }
908

909
    }
910

911
    // Load performance metrics
912
    for _, metricData := range testAnalytics.PerformanceMetrics {
913
        user, exists := users[metricData.Username]
914
        if !exists {
915
            return contextutils.ErrorWithContextf("user not found for performance metrics: %s", metricData.Username)
916
        }
917

918
        // Insert performance metric directly into database
919
        _, err := db.Exec(`
920
            INSERT INTO performance_metrics (user_id, topic, language, level, total_attempts, correct_attempts, average_response_time_ms, last_updated)
921
            VALUES ($1, $2, $3, $4, $5, $6, $7, NOW())
922
            ON CONFLICT (user_id, topic, language, level) DO UPDATE SET
923
                total_attempts = EXCLUDED.total_attempts,
924
                correct_attempts = EXCLUDED.correct_attempts,
925
                average_response_time_ms = EXCLUDED.average_response_time_ms,
926
                last_updated = NOW()
927
        `, user.ID, metricData.Topic, metricData.Language, metricData.Level,
928
            metricData.TotalAttempts, metricData.CorrectAttempts, metricData.AverageResponseTimeMs)
929
        if err != nil {
930
            return contextutils.WrapError(err, "failed to insert performance metric")
931
        }
932

933
    }
934

935
    // Load user question metadata (marked as known)
936
    for _, metadata := range testAnalytics.UserQuestionMetadata {
937
        user, exists := users[metadata.Username]
938
        if !exists {
939
            return contextutils.ErrorWithContextf("user not found for question metadata: %s", metadata.Username)
940
        }
941

942
        if metadata.QuestionIndex >= len(questions) {
943
            return contextutils.ErrorWithContextf("question index out of range for metadata: %d", metadata.QuestionIndex)
944
        }
945

946
        question := questions[metadata.QuestionIndex]
947

948
        if metadata.MarkedAsKnown {
949
            var markedAt time.Time
950
            if metadata.MarkedAsKnownAt != nil {
951
                var err error
952
                markedAt, err = time.Parse(time.RFC3339, *metadata.MarkedAsKnownAt)
953
                if err != nil {
954
                    return contextutils.ErrorWithContextf("invalid timestamp format for marked as known: %s", *metadata.MarkedAsKnownAt)
955
                }
956
            } else {
957
                markedAt = time.Now()
958
            }
959

960
            // Insert into user_question_metadata table
961
            _, err := db.Exec(`
962
                INSERT INTO user_question_metadata (user_id, question_id, marked_as_known, marked_as_known_at, created_at, updated_at)
963
                VALUES ($1, $2, $3, $4, NOW(), NOW())
964
                ON CONFLICT (user_id, question_id) DO UPDATE SET
965
                    marked_as_known = EXCLUDED.marked_as_known,
966
                    marked_as_known_at = EXCLUDED.marked_as_known_at,
967
                    updated_at = NOW()
968
            `, user.ID, question.ID, metadata.MarkedAsKnown, markedAt)
969
            if err != nil {
970
                return contextutils.WrapError(err, "failed to insert question metadata")
971
            }
972

973
        }
974
    }
975

976
    return nil
977
}
978

979
func loadAndCreateDailyAssignments(ctx context.Context, filePath string, users map[string]*models.User, questions []*models.Question, db *sql.DB, logger *observability.Logger) error {
980
    data, err := os.ReadFile(filePath)
981
    if err != nil {
982
        // File doesn't exist, skip daily assignments
983
        logger.Info(ctx, "Daily assignments file not found, skipping", map[string]interface{}{
984
            "file_path": filePath,
985
        })
986
        return nil
987
    }
988

989
    var testDailyAssignments TestDailyAssignments
990
    if err := yaml.Unmarshal(data, &testDailyAssignments); err != nil {
991
        return err
992
    }
993

994
    for _, assignmentData := range testDailyAssignments.DailyAssignments {
995
        user, exists := users[assignmentData.Username]
996
        if !exists {
997
            logger.Warn(ctx, "User not found for daily assignment", map[string]interface{}{
998
                "username": assignmentData.Username,
999
            })
1000
            continue
1001
        }
1002

1003
        // Parse the date
1004
        date, err := time.Parse("2006-01-02", assignmentData.Date)
1005
        if err != nil {
1006
            logger.Warn(ctx, "Invalid date format for daily assignment", map[string]interface{}{
1007
                "username": assignmentData.Username,
1008
                "date":     assignmentData.Date,
1009
            })
1010
            continue
1011
        }
1012

1013
        // Create a map of completed questions for quick lookup
1014
        completedQuestions := make(map[int]bool)
1015
        for _, qID := range assignmentData.CompletedQuestions {
1016
            completedQuestions[qID] = true
1017
        }
1018

1019
        // Assign questions to the user for the specific date
1020
        for _, questionID := range assignmentData.QuestionIDs {
1021
            // Check if question exists
1022
            if questionID <= 0 || questionID > len(questions) {
1023
                logger.Warn(ctx, "Question ID out of range for daily assignment", map[string]interface{}{
1024
                    "username":    assignmentData.Username,
1025
                    "date":        assignmentData.Date,
1026
                    "question_id": questionID,
1027
                })
1028
                continue
1029
            }
1030

1031
            question := questions[questionID-1] // Convert to 0-based index
1032

1033
            // Ensure we don't violate unique constraint by removing any existing assignment for the same
1034
            // (user_id, question_id, assignment_date) tuple before inserting. This avoids relying on
1035
            // ON CONFLICT which requires the constraint to be present in some test DB states.
1036
            deleteQuery := `DELETE FROM daily_question_assignments WHERE user_id = $1 AND question_id = $2 AND assignment_date = $3`
1037
            if _, err := db.ExecContext(ctx, deleteQuery, user.ID, question.ID, date); err != nil {
1038
                logger.Error(ctx, "Failed to delete existing daily assignment", err, map[string]interface{}{
1039
                    "username":    assignmentData.Username,
1040
                    "date":        assignmentData.Date,
1041
                    "question_id": questionID,
1042
                })
1043
                return contextutils.WrapErrorf(err, "failed to delete existing daily assignment for user %s, question %d", assignmentData.Username, questionID)
1044
            }
1045

1046
            // Insert the assignment directly into the database
1047
            query := `
1048
                INSERT INTO daily_question_assignments (user_id, question_id, assignment_date, is_completed, completed_at)
1049
                VALUES ($1, $2, $3, $4, $5)
1050
            `
1051

1052
            isCompleted := completedQuestions[questionID]
1053
            var completedAt *time.Time
1054
            if isCompleted {
1055
                now := time.Now()
1056
                completedAt = &now
1057
            }
1058

1059
            if _, err := db.ExecContext(ctx, query, user.ID, question.ID, date, isCompleted, completedAt); err != nil {
1060
                logger.Error(ctx, "Failed to create daily assignment", err, map[string]interface{}{
1061
                    "username":    assignmentData.Username,
1062
                    "date":        assignmentData.Date,
1063
                    "question_id": questionID,
1064
                })
1065
                return contextutils.WrapErrorf(err, "failed to create daily assignment for user %s, question %d", assignmentData.Username, questionID)
1066
            }
1067
        }
1068

1069
        logger.Info(ctx, "Created daily assignments", map[string]interface{}{
1070
            "username": assignmentData.Username,
1071
            "date":     assignmentData.Date,
1072
            "count":    len(assignmentData.QuestionIDs),
1073
        })
1074
    }
1075

1076
    return nil
1077
}
1078

1079
func loadAndCreateStories(ctx context.Context, filePath string, users map[string]*models.User, db *sql.DB, logger *observability.Logger) (map[string]TestStoryData, error) {
1080
    stories := make(map[string]TestStoryData)
1081
    data, err := os.ReadFile(filePath)
1082
    if err != nil {
1083
        // Stories file is optional, so just return if it doesn't exist
1084
        logger.Info(ctx, "Stories file not found, skipping", map[string]interface{}{
1085
            "file_path": filePath,
1086
        })
1087
        return stories, nil
1088
    }
1089

1090
    var testStories TestStories
1091
    if err := yaml.Unmarshal(data, &testStories); err != nil {
1092
        return stories, contextutils.WrapError(err, "failed to parse stories data")
1093
    }
1094

1095
    for i, storyData := range testStories.Stories {
1096
        user, exists := users[storyData.Username]
1097
        if !exists {
1098
            return stories, contextutils.ErrorWithContextf("user not found for story: %s", storyData.Username)
1099
        }
1100

1101
        // Parse section length override if provided
1102
        var sectionLengthOverride *models.SectionLength
1103
        if storyData.SectionLengthOverride != nil {
1104
            switch *storyData.SectionLengthOverride {
1105
            case "short":
1106
                sl := models.SectionLengthShort
1107
                sectionLengthOverride = &sl
1108
            case "medium":
1109
                sl := models.SectionLengthMedium
1110
                sectionLengthOverride = &sl
1111
            case "long":
1112
                sl := models.SectionLengthLong
1113
                sectionLengthOverride = &sl
1114
            }
1115
        }
1116

1117
        // Create story
1118
        story := &models.Story{
1119
            UserID:                uint(user.ID),
1120
            Title:                 storyData.Title,
1121
            Language:              storyData.Language,
1122
            Subject:               storyData.Subject,
1123
            AuthorStyle:           storyData.AuthorStyle,
1124
            TimePeriod:            storyData.TimePeriod,
1125
            Genre:                 storyData.Genre,
1126
            Tone:                  storyData.Tone,
1127
            CharacterNames:        storyData.CharacterNames,
1128
            CustomInstructions:    storyData.CustomInstructions,
1129
            SectionLengthOverride: sectionLengthOverride,
1130
            Status:                models.StoryStatus(storyData.Status),
1131
            CreatedAt:             time.Now(),
1132
            UpdatedAt:             time.Now(),
1133
        }
1134

1135
        // Insert story directly into database
1136
        _, err := db.Exec(`
1137
            INSERT INTO stories (user_id, title, language, subject, author_style, time_period, genre, tone,
1138
                                 character_names, custom_instructions, section_length_override, status,
1139
                                 created_at, updated_at)
1140
            VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
1141
        `, story.UserID, story.Title, story.Language, story.Subject, story.AuthorStyle, story.TimePeriod,
1142
            story.Genre, story.Tone, story.CharacterNames, story.CustomInstructions, story.SectionLengthOverride,
1143
            string(story.Status), story.CreatedAt, story.UpdatedAt)
1144
        if err != nil {
1145
            return stories, contextutils.WrapErrorf(err, "failed to insert story %d", i)
1146
        }
1147

1148
        // Get the story ID (we need to query it back since we don't have RETURNING)
1149
        var storyID int
1150
        err = db.QueryRow(`
1151
            SELECT id FROM stories WHERE user_id = $1 AND title = $2 ORDER BY created_at DESC LIMIT 1
1152
        `, story.UserID, story.Title).Scan(&storyID)
1153
        if err != nil {
1154
            return stories, contextutils.WrapErrorf(err, "failed to get story ID for story %d", i)
1155
        }
1156

1157
        // Initialize story data for test output
1158
        storyKey := fmt.Sprintf("%s_%s", storyData.Username, storyData.Title)
1159
        storyDataForOutput := TestStoryData{
1160
            ID:       storyID,
1161
            Username: storyData.Username,
1162
            Title:    storyData.Title,
1163
            Status:   storyData.Status,
1164
            Sections: []TestStorySectionData{},
1165
        }
1166

1167
        // Create sections for this story
1168
        for j, sectionData := range storyData.Sections {
1169
            section := &models.StorySection{
1170
                StoryID:        uint(storyID),
1171
                SectionNumber:  sectionData.SectionNumber,
1172
                Content:        sectionData.Content,
1173
                LanguageLevel:  sectionData.LanguageLevel,
1174
                WordCount:      sectionData.WordCount,
1175
                GeneratedBy:    models.GeneratorType(sectionData.GeneratedBy),
1176
                GeneratedAt:    time.Now(),
1177
                GenerationDate: time.Now(),
1178
            }
1179

1180
            // Insert section
1181
            _, err := db.Exec(`
1182
                INSERT INTO story_sections (story_id, section_number, content, language_level, word_count,
1183
                                           generated_by, generated_at, generation_date)
1184
                VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
1185
            `, section.StoryID, section.SectionNumber, section.Content, section.LanguageLevel,
1186
                section.WordCount, string(section.GeneratedBy), section.GeneratedAt, section.GenerationDate)
1187
            if err != nil {
1188
                return stories, contextutils.WrapErrorf(err, "failed to insert section %d for story %d", j, i)
1189
            }
1190

1191
            // Get the section ID
1192
            var sectionID int
1193
            err = db.QueryRow(`
1194
                SELECT id FROM story_sections WHERE story_id = $1 AND section_number = $2
1195
            `, section.StoryID, section.SectionNumber).Scan(&sectionID)
1196
            if err != nil {
1197
                return stories, contextutils.WrapErrorf(err, "failed to get section ID for section %d of story %d", j, i)
1198
            }
1199

1200
            // Add section data to story data for test output
1201
            sectionDataForOutput := TestStorySectionData{
1202
                ID:            sectionID,
1203
                StoryID:       storyID,
1204
                SectionNumber: section.SectionNumber,
1205
                Content:       section.Content,
1206
                LanguageLevel: section.LanguageLevel,
1207
                WordCount:     section.WordCount,
1208
                GeneratedBy:   string(section.GeneratedBy),
1209
            }
1210
            storyDataForOutput.Sections = append(storyDataForOutput.Sections, sectionDataForOutput)
1211

1212
            // Create questions for this section
1213
            for k, questionData := range sectionData.Questions {
1214
                question := &models.StorySectionQuestion{
1215
                    SectionID:          uint(sectionID),
1216
                    QuestionText:       questionData.QuestionText,
1217
                    Options:            questionData.Options,
1218
                    CorrectAnswerIndex: questionData.CorrectAnswerIndex,
1219
                    Explanation:        questionData.Explanation,
1220
                    CreatedAt:          time.Now(),
1221
                }
1222

1223
                // Convert options to JSON for database storage
1224
                optionsJSON, err := json.Marshal(question.Options)
1225
                if err != nil {
1226
                    return stories, contextutils.WrapErrorf(err, "failed to marshal options for question %d for section %d of story %d", k, j, i)
1227
                }
1228

1229
                // Insert question
1230
                _, err = db.Exec(`
1231
                    INSERT INTO story_section_questions (section_id, question_text, options, correct_answer_index, explanation, created_at)
1232
                    VALUES ($1, $2, $3, $4, $5, $6)
1233
                `, question.SectionID, question.QuestionText, optionsJSON, question.CorrectAnswerIndex,
1234
                    question.Explanation, question.CreatedAt)
1235
                if err != nil {
1236
                    return stories, contextutils.WrapErrorf(err, "failed to insert question %d for section %d of story %d", k, j, i)
1237
                }
1238
            }
1239
        }
1240

1241
        // Store story data for test output after all sections are created
1242
        stories[storyKey] = storyDataForOutput
1243

1244
        logger.Info(ctx, "Created test story", map[string]interface{}{
1245
            "username": storyData.Username,
1246
            "title":    storyData.Title,
1247
            "story_id": storyID,
1248
        })
1249
    }
1250

1251
    return stories, nil
1252
}
1253

1254
// loadAndCreateSnippets loads and creates snippets from test data
1255
func loadAndCreateSnippets(ctx context.Context, filePath string, users map[string]*models.User, db *sql.DB, logger *observability.Logger) (map[string]TestSnippetData, error) {
1256
    snippets := make(map[string]TestSnippetData)
1257
    data, err := os.ReadFile(filePath)
1258
    if err != nil {
1259
        // Snippets file is optional, so just return if it doesn't exist
1260
        logger.Info(ctx, "Snippets file not found, skipping", map[string]interface{}{
1261
            "file_path": filePath,
1262
        })
1263
        return snippets, nil
1264
    }
1265

1266
    var testSnippets TestSnippets
1267
    if err := yaml.Unmarshal(data, &testSnippets); err != nil {
1268
        return snippets, contextutils.WrapError(err, "failed to parse snippets data")
1269
    }
1270

1271
    // Create snippets service
1272
    snippetsService := services.NewSnippetsService(db, nil, logger)
1273

1274
    for i, snippetData := range testSnippets.Snippets {
1275
        user, exists := users[snippetData.Username]
1276
        if !exists {
1277
            return snippets, contextutils.ErrorWithContextf("user not found for snippet: %s", snippetData.Username)
1278
        }
1279

1280
        // Create snippet request
1281
        createReq := api.CreateSnippetRequest{
1282
            OriginalText:   snippetData.OriginalText,
1283
            TranslatedText: snippetData.TranslatedText,
1284
            SourceLanguage: snippetData.SourceLanguage,
1285
            TargetLanguage: snippetData.TargetLanguage,
1286
            Context:        snippetData.Context,
1287
        }
1288

1289
        // Create snippet using the service
1290
        snippet, err := snippetsService.CreateSnippet(ctx, int64(user.ID), createReq)
1291
        if err != nil {
1292
            return snippets, contextutils.WrapErrorf(err, "failed to create snippet %d", i)
1293
        }
1294

1295
        // Initialize snippet data for test output
1296
        snippetKey := fmt.Sprintf("%s_%s_%s", snippetData.Username, snippetData.OriginalText, snippetData.SourceLanguage)
1297
        snippets[snippetKey] = TestSnippetData{
1298
            ID:             int(snippet.ID),
1299
            Username:       snippetData.Username,
1300
            OriginalText:   snippet.OriginalText,
1301
            TranslatedText: snippet.TranslatedText,
1302
            SourceLanguage: snippet.SourceLanguage,
1303
            TargetLanguage: snippet.TargetLanguage,
1304
        }
1305

1306
        logger.Info(ctx, "Created test snippet", map[string]interface{}{
1307
            "username":      snippetData.Username,
1308
            "original_text": snippetData.OriginalText,
1309
            "snippet_id":    snippet.ID,
1310
        })
1311
    }
1312

1313
    return snippets, nil
1314
}
1315

1316
// outputUserDataForTests outputs the created user data to a JSON file for E2E tests to read
1317
func outputUserDataForTests(users map[string]*models.User, rootDir string, logger *observability.Logger) error {
1318
    // Create a simplified structure for the E2E test
1319
    type TestUserData struct {
1320
        ID       int    `json:"id"`
1321
        Username string `json:"username"`
1322
        Email    string `json:"email"`
1323
    }
1324

1325
    userData := make(map[string]TestUserData)
1326
    for username, user := range users {
1327
        userData[username] = TestUserData{
1328
            ID:       user.ID,
1329
            Username: user.Username,
1330
            Email:    user.Email.String,
1331
        }
1332
    }
1333

1334
    // Write to JSON file in the frontend/tests directory
1335
    outputPath := filepath.Join(rootDir, "..", "frontend", "tests", "test-users.json")
1336

1337
    // Ensure the directory exists
1338
    outputDir := filepath.Dir(outputPath)
1339
    if err := os.MkdirAll(outputDir, 0o755); err != nil {
1340
        return contextutils.WrapErrorf(err, "failed to create output directory: %s", outputDir)
1341
    }
1342

1343
    // Marshal to JSON with pretty printing
1344
    jsonData, err := json.MarshalIndent(userData, "", "  ")
1345
    if err != nil {
1346
        return contextutils.WrapErrorf(err, "failed to marshal user data to JSON")
1347
    }
1348

1349
    // Write to file
1350
    if err := os.WriteFile(outputPath, jsonData, 0o644); err != nil {
1351
        return contextutils.WrapErrorf(err, "failed to write user data to file: %s", outputPath)
1352
    }
1353

1354
    logger.Info(context.Background(), "Output user data for E2E tests", map[string]interface{}{
1355
        "file_path":  outputPath,
1356
        "user_count": len(userData),
1357
    })
1358

1359
    return nil
1360
}
1361

1362
// outputStoryDataForTests outputs the created story data to a JSON file for E2E tests to read
1363
func outputStoryDataForTests(stories map[string]TestStoryData, rootDir string, logger *observability.Logger) error {
1364
    // Write to JSON file in the frontend/tests directory
1365
    outputPath := filepath.Join(rootDir, "..", "frontend", "tests", "test-stories.json")
1366

1367
    // Ensure the directory exists
1368
    outputDir := filepath.Dir(outputPath)
1369
    if err := os.MkdirAll(outputDir, 0o755); err != nil {
1370
        return contextutils.WrapErrorf(err, "failed to create output directory: %s", outputDir)
1371
    }
1372

1373
    // Marshal to JSON with pretty printing
1374
    jsonData, err := json.MarshalIndent(stories, "", "  ")
1375
    if err != nil {
1376
        return contextutils.WrapErrorf(err, "failed to marshal stories data to JSON")
1377
    }
1378

1379
    // Write to file
1380
    if err := os.WriteFile(outputPath, jsonData, 0o644); err != nil {
1381
        return contextutils.WrapErrorf(err, "failed to write stories data to file: %s", outputPath)
1382
    }
1383

1384
    logger.Info(context.Background(), "Output stories data for E2E tests", map[string]interface{}{
1385
        "file_path":     outputPath,
1386
        "stories_count": len(stories),
1387
    })
1388

1389
    return nil
1390
}
1391

1392
// outputSnippetDataForTests outputs the created snippet data to a JSON file for E2E tests to read
1393
func outputSnippetDataForTests(snippets map[string]TestSnippetData, rootDir string, logger *observability.Logger) error {
1394
    // Write to JSON file in the frontend/tests directory
1395
    outputPath := filepath.Join(rootDir, "..", "frontend", "tests", "test-snippets.json")
1396

1397
    // Ensure the directory exists
1398
    outputDir := filepath.Dir(outputPath)
1399
    if err := os.MkdirAll(outputDir, 0o755); err != nil {
1400
        return contextutils.WrapErrorf(err, "failed to create output directory: %s", outputDir)
1401
    }
1402

1403
    // Marshal to JSON with pretty printing
1404
    jsonData, err := json.MarshalIndent(snippets, "", "  ")
1405
    if err != nil {
1406
        return contextutils.WrapErrorf(err, "failed to marshal snippets data to JSON")
1407
    }
1408

1409
    // Write to file
1410
    if err := os.WriteFile(outputPath, jsonData, 0o644); err != nil {
1411
        return contextutils.WrapErrorf(err, "failed to write snippets data to file: %s", outputPath)
1412
    }
1413

1414
    logger.Info(context.Background(), "Output snippets data for E2E tests", map[string]interface{}{
1415
        "file_path":      outputPath,
1416
        "snippets_count": len(snippets),
1417
    })
1418

1419
    return nil
1420
}
1421

1422
// outputRolesDataForTests outputs the created roles data to a JSON file for E2E tests to read
1423
func outputRolesDataForTests(db *sql.DB, rootDir string, logger *observability.Logger) error {
1424
    // Query all roles from the database
1425
    rows, err := db.Query(`
1426
        SELECT id, name, description, created_at, updated_at
1427
        FROM roles
1428
        ORDER BY id
1429
    `)
1430
    if err != nil {
1431
        return contextutils.WrapErrorf(err, "failed to query roles from database")
1432
    }
1433
    defer func() {
1434
        if err := rows.Close(); err != nil {
1435
            logger.Warn(context.Background(), "Warning: failed to close rows", map[string]interface{}{"error": err.Error()})
1436
        }
1437
    }()
1438

1439
    // Create a simplified structure for the E2E test
1440
    type TestRoleData struct {
1441
        ID          int    `json:"id"`
1442
        Name        string `json:"name"`
1443
        Description string `json:"description"`
1444
    }
1445

1446
    roleData := make(map[string]TestRoleData)
1447
    for rows.Next() {
1448
        var role models.Role
1449
        err := rows.Scan(&role.ID, &role.Name, &role.Description, &role.CreatedAt, &role.UpdatedAt)
1450
        if err != nil {
1451
            return contextutils.WrapErrorf(err, "failed to scan role data")
1452
        }
1453
        roleData[role.Name] = TestRoleData{
1454
            ID:          role.ID,
1455
            Name:        role.Name,
1456
            Description: role.Description,
1457
        }
1458
    }
1459

1460
    if err := rows.Err(); err != nil {
1461
        return contextutils.WrapErrorf(err, "error iterating over roles")
1462
    }
1463

1464
    // Write to JSON file in the frontend/tests directory
1465
    outputPath := filepath.Join(rootDir, "..", "frontend", "tests", "test-roles.json")
1466

1467
    // Ensure the directory exists
1468
    outputDir := filepath.Dir(outputPath)
1469
    if err := os.MkdirAll(outputDir, 0o755); err != nil {
1470
        return contextutils.WrapErrorf(err, "failed to create output directory: %s", outputDir)
1471
    }
1472

1473
    // Marshal to JSON with pretty printing
1474
    jsonData, err := json.MarshalIndent(roleData, "", "  ")
1475
    if err != nil {
1476
        return contextutils.WrapErrorf(err, "failed to marshal roles data to JSON")
1477
    }
1478

1479
    // Write to file
1480
    if err := os.WriteFile(outputPath, jsonData, 0o644); err != nil {
1481
        return contextutils.WrapErrorf(err, "failed to write roles data to file: %s", outputPath)
1482
    }
1483

1484
    logger.Info(context.Background(), "Output roles data for E2E tests", map[string]interface{}{
1485
        "file_path":   outputPath,
1486
        "roles_count": len(roleData),
1487
    })
1488

1489
    return nil
1490
}
1491

1492
func loadAndCreateConversations(ctx context.Context, filePath string, users map[string]*models.User, db *sql.DB, logger *observability.Logger) (map[string]TestConversationData, error) {
1493
    conversations := make(map[string]TestConversationData)
1494
    data, err := os.ReadFile(filePath)
1495
    if err != nil {
1496
        // Conversations file is optional, so just return if it doesn't exist
1497
        logger.Info(ctx, "Conversations file not found, skipping", map[string]interface{}{
1498
            "file_path": filePath,
1499
        })
1500
        return conversations, nil
1501
    }
1502

1503
    var testConversations TestConversations
1504
    if err := yaml.Unmarshal(data, &testConversations); err != nil {
1505
        return conversations, contextutils.WrapError(err, "failed to parse conversations data")
1506
    }
1507

1508
    // Create conversation service
1509
    conversationService := services.NewConversationService(db)
1510

1511
    for i, convData := range testConversations.Conversations {
1512
        user, exists := users[convData.Username]
1513
        if !exists {
1514
            return conversations, contextutils.ErrorWithContextf("user not found for conversation: %s", convData.Username)
1515
        }
1516

1517
        // Create conversation
1518
        createReq := &api.CreateConversationRequest{
1519
            Title: convData.Title,
1520
        }
1521

1522
        conversation, err := conversationService.CreateConversation(ctx, uint(user.ID), createReq)
1523
        if err != nil {
1524
            return conversations, contextutils.WrapErrorf(err, "failed to create conversation %d", i)
1525
        }
1526

1527
        // Store conversation data for test output (messages will be added below)
1528
        convKey := fmt.Sprintf("%s_%s", convData.Username, convData.Title)
1529
        conversations[convKey] = TestConversationData{
1530
            ID:       conversation.Id.String(),
1531
            Username: convData.Username,
1532
            Title:    convData.Title,
1533
            Messages: []TestMessageData{},
1534
        }
1535

1536
        // Create messages for this conversation
1537
        for j, msgData := range convData.Messages {
1538
            content := struct {
1539
                Text *string `json:"text,omitempty"`
1540
            }{
1541
                Text: &msgData.Content,
1542
            }
1543

1544
            createMsgReq := &api.CreateMessageRequest{
1545
                Content:    content,
1546
                Role:       api.CreateMessageRequestRole(msgData.Role),
1547
                QuestionId: msgData.QuestionID,
1548
            }
1549

1550
            _, err := conversationService.AddMessage(ctx, conversation.Id.String(), uint(user.ID), createMsgReq)
1551
            if err != nil {
1552
                return conversations, contextutils.WrapErrorf(err, "failed to add message %d for conversation %d", j, i)
1553
            }
1554
        }
1555

1556
        // Now retrieve all messages for this conversation to get their actual data
1557
        messages, err := conversationService.GetConversationMessages(ctx, conversation.Id.String(), uint(user.ID))
1558
        if err != nil {
1559
            return conversations, contextutils.WrapErrorf(err, "failed to get messages for conversation %d", i)
1560
        }
1561

1562
        // Convert messages to our test data format
1563
        var testMessages []TestMessageData
1564
        for _, msg := range messages {
1565
            testMsg := TestMessageData{
1566
                ID:             msg.Id.String(),
1567
                ConversationID: msg.ConversationId.String(),
1568
                Role:           string(msg.Role),
1569
                Bookmarked:     false, // Default value
1570
                CreatedAt:      msg.CreatedAt.Format(time.RFC3339),
1571
                UpdatedAt:      msg.UpdatedAt.Format(time.RFC3339),
1572
            }
1573

1574
            if msg.QuestionId != nil {
1575
                testMsg.QuestionID = msg.QuestionId
1576
            }
1577

1578
            if msg.Content.Text != nil {
1579
                testMsg.Content = *msg.Content.Text
1580
            }
1581

1582
            testMessages = append(testMessages, testMsg)
1583
        }
1584

1585
        // Update the conversation with the actual messages
1586
        conversations[convKey] = TestConversationData{
1587
            ID:       conversation.Id.String(),
1588
            Username: convData.Username,
1589
            Title:    convData.Title,
1590
            Messages: testMessages,
1591
        }
1592

1593
        logger.Info(ctx, "Created test conversation", map[string]interface{}{
1594
            "username":        convData.Username,
1595
            "title":           convData.Title,
1596
            "conversation_id": conversation.Id,
1597
        })
1598
    }
1599

1600
    return conversations, nil
1601
}
1602

1603
// outputConversationDataForTests outputs the created conversation data to a JSON file for E2E tests to read
1604
func outputConversationDataForTests(conversations map[string]TestConversationData, rootDir string, logger *observability.Logger) error {
1605
    // Write to JSON file in the frontend/tests directory
1606
    outputPath := filepath.Join(rootDir, "..", "frontend", "tests", "test-conversations.json")
1607

1608
    // Ensure the directory exists
1609
    outputDir := filepath.Dir(outputPath)
1610
    if err := os.MkdirAll(outputDir, 0o755); err != nil {
1611
        return contextutils.WrapErrorf(err, "failed to create output directory: %s", outputDir)
1612
    }
1613

1614
    // Marshal to JSON with pretty printing
1615
    jsonData, err := json.MarshalIndent(conversations, "", "  ")
1616
    if err != nil {
1617
        return contextutils.WrapErrorf(err, "failed to marshal conversations data to JSON")
1618
    }
1619

1620
    // Write to file
1621
    if err := os.WriteFile(outputPath, jsonData, 0o644); err != nil {
1622
        return contextutils.WrapErrorf(err, "failed to write conversations data to file: %s", outputPath)
1623
    }
1624

1625
    logger.Info(context.Background(), "Output conversations data for E2E tests", map[string]interface{}{
1626
        "file_path":           outputPath,
1627
        "conversations_count": len(conversations),
1628
    })
1629

1630
    return nil
1631
}
1632

1633
// loadAndCreateFeedback loads and creates feedback reports from test data
1634
func loadAndCreateFeedback(ctx context.Context, filePath string, users map[string]*models.User, db *sql.DB, logger *observability.Logger) (map[string]TestFeedbackData, error) {
1635
    feedback := make(map[string]TestFeedbackData)
1636
    data, err := os.ReadFile(filePath)
1637
    if err != nil {
1638
        // Feedback file is optional, so just return if it doesn't exist
1639
        logger.Info(ctx, "Feedback file not found, skipping", map[string]interface{}{
1640
            "file_path": filePath,
1641
        })
1642
        return feedback, nil
1643
    }
1644

1645
    var testFeedback TestFeedback
1646
    if err := yaml.Unmarshal(data, &testFeedback); err != nil {
1647
        return feedback, contextutils.WrapError(err, "failed to parse feedback data")
1648
    }
1649

1650
    for i, feedbackData := range testFeedback.FeedbackReports {
1651
        user, exists := users[feedbackData.Username]
1652
        if !exists {
1653
            return feedback, contextutils.ErrorWithContextf("user not found for feedback: %s", feedbackData.Username)
1654
        }
1655

1656
        // Default values
1657
        feedbackType := feedbackData.FeedbackType
1658
        if feedbackType == "" {
1659
            feedbackType = "general"
1660
        }
1661
        status := feedbackData.Status
1662
        if status == "" {
1663
            status = "new"
1664
        }
1665

1666
        // Marshal context_data to JSON
1667
        contextJSON, err := json.Marshal(feedbackData.ContextData)
1668
        if err != nil {
1669
            return feedback, contextutils.WrapErrorf(err, "failed to marshal context_data for feedback %d", i)
1670
        }
1671

1672
        // Insert feedback directly into database
1673
        var feedbackID int
1674
        err = db.QueryRow(`
1675
            INSERT INTO feedback_reports (user_id, feedback_text, feedback_type, context_data, status, created_at, updated_at)
1676
            VALUES ($1, $2, $3, $4, $5, NOW(), NOW())
1677
            RETURNING id
1678
        `, user.ID, feedbackData.FeedbackText, feedbackType, contextJSON, status).Scan(&feedbackID)
1679
        if err != nil {
1680
            return feedback, contextutils.WrapErrorf(err, "failed to insert feedback %d", i)
1681
        }
1682

1683
        // Store feedback data for test output
1684
        feedbackKey := fmt.Sprintf("%s_%d", feedbackData.Username, i)
1685
        feedback[feedbackKey] = TestFeedbackData{
1686
            ID:           feedbackID,
1687
            Username:     feedbackData.Username,
1688
            FeedbackText: feedbackData.FeedbackText,
1689
            FeedbackType: feedbackType,
1690
            Status:       status,
1691
            ContextData:  feedbackData.ContextData,
1692
        }
1693

1694
        logger.Info(ctx, "Created test feedback", map[string]interface{}{
1695
            "username":      feedbackData.Username,
1696
            "feedback_id":   feedbackID,
1697
            "status":        status,
1698
            "feedback_type": feedbackType,
1699
        })
1700
    }
1701

1702
    return feedback, nil
1703
}
1704

1705
// outputFeedbackDataForTests outputs the created feedback data to a JSON file for E2E tests to read
1706
func outputFeedbackDataForTests(feedback map[string]TestFeedbackData, rootDir string, logger *observability.Logger) error {
1707
    // Write to JSON file in the frontend/tests directory
1708
    outputPath := filepath.Join(rootDir, "..", "frontend", "tests", "test-feedback.json")
1709

1710
    // Ensure the directory exists
1711
    outputDir := filepath.Dir(outputPath)
1712
    if err := os.MkdirAll(outputDir, 0o755); err != nil {
1713
        return contextutils.WrapErrorf(err, "failed to create output directory: %s", outputDir)
1714
    }
1715

1716
    // Marshal to JSON with pretty printing
1717
    jsonData, err := json.MarshalIndent(feedback, "", "  ")
1718
    if err != nil {
1719
        return contextutils.WrapErrorf(err, "failed to marshal feedback data to JSON")
1720
    }
1721

1722
    // Write to file
1723
    if err := os.WriteFile(outputPath, jsonData, 0o644); err != nil {
1724
        return contextutils.WrapErrorf(err, "failed to write feedback data to file: %s", outputPath)
1725
    }
1726

1727
    logger.Info(context.Background(), "Output feedback data for E2E tests", map[string]interface{}{
1728
        "file_path":      outputPath,
1729
        "feedback_count": len(feedback),
1730
    })
1731

1732
    return nil
1733
}
1734


			
quizapp cmd worker
0.0%
Statements
0/145
main.go
0.0%
0/145
quizapp cmd worker main.go
0.0%
Statements
0/145
1
// Package main provides the entry point for the Quiz Application worker service.
2
package main
3

4
import (
5
    "context"
6
    "io/fs"
7
    "net/http"
8
    "os"
9
    "os/signal"
10
    "syscall"
11
    "time"
12

13
    "quizapp/internal/config"
14
    "quizapp/internal/database"
15
    "quizapp/internal/handlers"
16
    "quizapp/internal/middleware"
17
    "quizapp/internal/observability"
18
    "quizapp/internal/services"
19
    "quizapp/internal/version"
20
    "quizapp/internal/worker"
21

22
    "github.com/gin-contrib/sessions"
23
    "github.com/gin-contrib/sessions/cookie"
24
    "github.com/gin-gonic/gin"
25
)
26

27
// fatalIfErr logs the error with context and panics with a consistent message
28
func fatalIfErr(ctx context.Context, logger *observability.Logger, msg string, err error, fields map[string]interface{}) {
29
    logger.Error(ctx, msg, err, fields)
30
    panic(msg + ": " + err.Error())
31
}
32

33
func main() {
34
    ctx := context.Background()
35

36
    // Load configuration
37
    cfg, err := config.NewConfig()
38
    if err != nil {
39
        panic("Failed to load configuration: " + err.Error())
40
    }
41

42
    // Setup observability (tracing/metrics/logging)
43
    tp, mp, logger, err := observability.SetupObservability(&cfg.OpenTelemetry, "quiz-worker")
44
    if err != nil {
45
        panic("Failed to initialize observability: " + err.Error())
46
    }
47
    defer func() {
48
        if tp != nil {
49
            if err := tp.Shutdown(context.TODO()); err != nil {
50
                logger.Warn(ctx, "Error shutting down tracer provider", map[string]interface{}{"error": err.Error(), "provider": "tracer"})
51
            }
52
        }
53
        if mp != nil {
54
            if err := mp.Shutdown(context.TODO()); err != nil {
55
                logger.Warn(ctx, "Error shutting down meter provider", map[string]interface{}{"error": err.Error(), "provider": "meter"})
56
            }
57
        }
58
    }()
59

60
    logger.Info(ctx, "Starting quiz worker service", map[string]interface{}{
61
        "port":     cfg.Server.WorkerPort,
62
        "logLevel": cfg.Server.LogLevel,
63
        "debug":    cfg.Server.Debug,
64
    })
65

66
    // Initialize database manager with logger
67
    dbManager := database.NewManager(logger)
68

69
    // Initialize database connection without running migrations (migrations are managed elsewhere)
70
    db, err := dbManager.InitDBWithoutMigrations(cfg.Database)
71
    if err != nil {
72
        fatalIfErr(ctx, logger, "Failed to initialize database", err, map[string]interface{}{"db_url": cfg.Database.URL})
73
    }
74
    defer func() {
75
        if err := db.Close(); err != nil {
76
            logger.Warn(ctx, "Warning: failed to close database", map[string]interface{}{"error": err.Error(), "db_url": cfg.Database.URL})
77
        }
78
    }()
79

80
    // Initialize services
81
    userService := services.NewUserServiceWithLogger(db, cfg, logger)
82
    learningService := services.NewLearningServiceWithLogger(db, cfg, logger)
83
    // Create question service
84
    questionService := services.NewQuestionServiceWithLogger(db, learningService, cfg, logger)
85
    // Create usage stats service
86
    usageStatsService := services.NewUsageStatsService(cfg, db, logger)
87
    aiService := services.NewAIService(cfg, logger, usageStatsService)
88
    workerService := services.NewWorkerServiceWithLogger(db, logger)
89
    generationHintService := services.NewGenerationHintService(db, logger)
90
    emailService := services.CreateEmailServiceWithDB(cfg, logger, db)
91
    // Create daily question service
92
    dailyQuestionService := services.NewDailyQuestionService(db, logger, questionService, learningService)
93

94
    // Create word of the day service
95
    wordOfTheDayService := services.NewWordOfTheDayService(db, logger)
96

97
    // Create story service
98
    storyService := services.NewStoryService(db, cfg, logger)
99

100
    // Create translation cache repository
101
    translationCacheRepo := services.NewTranslationCacheRepository(db, logger)
102

103
    // Initialize worker with the observability logger
104
    workerInstance := worker.NewWorker(userService, questionService, aiService, learningService, workerService, dailyQuestionService, wordOfTheDayService, storyService, emailService, generationHintService, translationCacheRepo, "default", cfg, logger)
105
    go workerInstance.Start(ctx)
106

107
    // Initialize admin handler for worker UI
108
    adminHandler := handlers.NewWorkerAdminHandlerWithLogger(userService, questionService, aiService, cfg, workerInstance, workerService, learningService, dailyQuestionService, logger)
109

110
    // Setup Gin router
111
    gin.SetMode(gin.ReleaseMode)
112
    if cfg.Server.Debug {
113
        gin.SetMode(gin.DebugMode)
114
    }
115
    router := gin.New()
116
    router.Use(gin.Recovery())
117

118
    // Add HTTP request logging middleware using our observability logger
119
    router.Use(func(c *gin.Context) {
120
        start := time.Now()
121

122
        // Process request
123
        c.Next()
124

125
        // Log request details using our observability logger
126
        latency := time.Since(start)
127
        statusCode := c.Writer.Status()
128
        clientIP := c.ClientIP()
129
        method := c.Request.Method
130
        path := c.Request.URL.Path
131

132
        // Create structured log entry
133
        fields := map[string]interface{}{
134
            "http.method":      method,
135
            "http.path":        path,
136
            "http.status_code": statusCode,
137
            "http.latency_ms":  latency.Milliseconds(),
138
            "http.client_ip":   clientIP,
139
            "http.user_agent":  c.Request.UserAgent(),
140
        }
141

142
        // Add error message if present
143
        if len(c.Errors) > 0 {
144
            fields["http.error"] = c.Errors.String()
145
        }
146

147
        // Log using our observability logger (goes to both stdout and OTLP)
148
        // Use appropriate log level based on status code
149
        if statusCode >= 500 {
150
            logger.Error(c.Request.Context(), "HTTP request failed", nil, fields)
151
        } else if statusCode >= 400 {
152
            logger.Warn(c.Request.Context(), "HTTP request warning", fields)
153
        } else {
154
            logger.Info(c.Request.Context(), "HTTP request", fields)
155
        }
156
    })
157

158
    // Add OpenTelemetry middleware for HTTP tracing with automatic error attributes
159
    router.Use(observability.GinMiddlewareWithErrorHandling("quiz-worker"))
160

161
    // Add CORS middleware
162
    router.Use(func(c *gin.Context) {
163
        c.Header("Access-Control-Allow-Origin", "*")
164
        c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
165
        c.Header("Access-Control-Allow-Headers", "Origin, Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization")
166

167
        if c.Request.Method == "OPTIONS" {
168
            c.AbortWithStatus(204)
169
            return
170
        }
171

172
        c.Next()
173
    })
174

175
    // Setup session middleware
176
    store := cookie.NewStore([]byte(cfg.Server.SessionSecret))
177
    router.Use(sessions.Sessions(config.SessionName, store))
178

179
    // Setup routes
180
    v1 := router.Group("/v1")
181
    {
182
        // Health check route
183
        v1.GET("/health", func(c *gin.Context) {
184
            c.JSON(http.StatusOK, gin.H{"status": "ok"})
185
        })
186

187
        // Version route
188
        v1.GET("/version", func(c *gin.Context) {
189
            c.JSON(http.StatusOK, gin.H{
190
                "service":   "worker",
191
                "version":   version.Version,
192
                "commit":    version.Commit,
193
                "buildTime": version.BuildTime,
194
            })
195
        })
196
    }
197

198
    // Serve static assets (CSS/JS) for worker admin dashboard
199
    staticFS, _ := fs.Sub(handlers.AssetsFS, "templates/assets")
200
    router.StaticFS("/worker", http.FS(staticFS))
201

202
    // Config dump endpoint
203
    router.GET("/configz", adminHandler.GetConfigz)
204

205
    // API routes for worker management
206
    api := router.Group("/v1")
207
    {
208
        // Admin worker endpoints (for frontend)
209
        adminWorker := api.Group("/admin/worker")
210
        adminWorker.Use(middleware.RequireAuth())
211
        {
212
            adminWorker.GET("/details", adminHandler.GetWorkerDetails)
213
            adminWorker.GET("/status", adminHandler.GetWorkerStatus)
214
            adminWorker.GET("/logs", adminHandler.GetActivityLogs)
215
            adminWorker.POST("/pause", adminHandler.PauseWorker)
216
            adminWorker.POST("/resume", adminHandler.ResumeWorker)
217
            adminWorker.POST("/trigger", adminHandler.TriggerWorkerRun)
218
            adminWorker.GET("/ai-concurrency", adminHandler.GetAIConcurrencyStats)
219
        }
220

221
        // Worker user control endpoints (for pausing/resuming user question generation)
222
        workerUsers := api.Group("/admin/worker/users")
223
        workerUsers.Use(middleware.RequireAuth())
224
        {
225
            workerUsers.GET("/", adminHandler.GetWorkerUsers)
226
            workerUsers.POST("/pause", adminHandler.PauseWorkerUser)
227
            workerUsers.POST("/resume", adminHandler.ResumeWorkerUser)
228
        }
229

230
        // System health for worker
231
        system := api.Group("/system")
232
        {
233
            system.GET("/health", adminHandler.GetSystemHealth)
234
        }
235

236
        // Admin analytics endpoints (for frontend)
237
        adminAnalytics := api.Group("/admin/worker/analytics")
238
        adminAnalytics.Use(middleware.RequireAuth())
239
        {
240
            adminAnalytics.GET("/priority-scores", adminHandler.GetPriorityAnalytics)
241
            adminAnalytics.GET("/user-performance", adminHandler.GetUserPerformanceAnalytics)
242
            adminAnalytics.GET("/generation-intelligence", adminHandler.GetGenerationIntelligence)
243
            adminAnalytics.GET("/system-health", adminHandler.GetSystemHealthAnalytics)
244
            adminAnalytics.GET("/comparison", adminHandler.GetUserComparisonAnalytics)
245
            adminAnalytics.GET("/user/:userID", adminHandler.GetUserPriorityAnalytics)
246
        }
247

248
        // Admin daily questions endpoints (for frontend)
249
        adminDaily := api.Group("/admin/worker/daily")
250
        adminDaily.Use(middleware.RequireAuth())
251
        {
252
            adminDaily.GET("/users/:userId/questions/:date", adminHandler.GetUserDailyQuestions)
253
            adminDaily.POST("/users/:userId/questions/:date/regenerate", adminHandler.RegenerateUserDailyQuestions)
254
        }
255

256
        // Admin notification endpoints (for frontend)
257
        adminNotifications := api.Group("/admin/worker/notifications")
258
        adminNotifications.Use(middleware.RequireAuth())
259
        {
260
            adminNotifications.GET("/stats", adminHandler.GetNotificationStats)
261
            adminNotifications.GET("/errors", adminHandler.GetNotificationErrors)
262
            adminNotifications.GET("/sent", adminHandler.GetSentNotifications)
263
            adminNotifications.POST("/test/create-sent", adminHandler.CreateTestSentNotification)
264
            adminNotifications.POST("/force-send", adminHandler.ForceSendNotification)
265
        }
266
    }
267

268
    // Automatic route listing at root path
269
    routeListing := handlers.NewRouteListingHandler("Worker")
270
    routeListing.CollectRoutes(router)
271

272
    // Root path shows all available routes
273
    router.GET("/", func(c *gin.Context) {
274
        // Support JSON output via query parameter
275
        if c.Query("json") == "true" {
276
            routeListing.GetRouteListingJSON(c)
277
        } else {
278
            routeListing.GetRouteListingPage(c)
279
        }
280
    })
281

282
    // Create HTTP server
283
    srv := &http.Server{
284
        Addr:    ":" + cfg.Server.WorkerPort,
285
        Handler: router,
286
    }
287

288
    // Start server in a goroutine
289
    go func() {
290
        logger.Info(ctx, "Worker server starting", map[string]interface{}{"port": cfg.Server.WorkerPort})
291
        if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
292
            fatalIfErr(ctx, logger, "Failed to start worker server", err, map[string]interface{}{"port": cfg.Server.WorkerPort})
293
        }
294
    }()
295

296
    // Wait for interrupt signal to gracefully shutdown
297
    quit := make(chan os.Signal, 1)
298
    signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
299
    <-quit
300
    logger.Info(ctx, "Worker server shutting down", map[string]interface{}{"service": "worker"})
301

302
    // Graceful shutdown with timeout
303
    shutdownCtx, shutdownCancel := context.WithTimeout(ctx, config.WorkerShutdownTimeout)
304
    defer shutdownCancel()
305

306
    // Shutdown the worker first
307
    if err := workerInstance.Shutdown(shutdownCtx); err != nil {
308
        logger.Warn(ctx, "Warning: failed to shutdown worker", map[string]interface{}{"error": err.Error(), "service": "worker"})
309
    }
310

311
    // Then shutdown the server
312
    if err := srv.Shutdown(shutdownCtx); err != nil {
313
        fatalIfErr(ctx, logger, "Worker server forced to shutdown", err, map[string]interface{}{"service": "worker"})
314
    }
315

316
    logger.Info(ctx, "Worker server exited", map[string]interface{}{"service": "worker"})
317
}
318


			
quizapp internal
55.4%
Statements
8921/16092
api
0.0%
0/240
config
79.8%
162/203
database
81.5%
181/222
di
95.8%
113/118
handlers
49.8%
2615/5256
middleware
47.9%
359/749
models
42.9%
36/84
observability
51.6%
115/223
services
58.0%
4554/7854
utils
70.0%
147/210
worker
68.5%
639/933
quizapp internal api
0.0%
Statements
0/240
generated.go
0.0%
0/240
quizapp internal api generated.go
0.0%
Statements
0/240
1
// Package api provides primitives to interact with the openapi HTTP API.
2
//
3
// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.5.0 DO NOT EDIT.
4
package api
5

6
import (
7
    "encoding/json"
8
    "fmt"
9
    "time"
10

11
    "github.com/oapi-codegen/runtime"
12
    openapi_types "github.com/oapi-codegen/runtime/types"
13
)
14

15
const (
16
    ApiKeyQueryScopes = "apiKeyQuery.Scopes"
17
    BearerAuthScopes  = "bearerAuth.Scopes"
18
    CookieAuthScopes  = "cookieAuth.Scopes"
19
    SessionAuthScopes = "sessionAuth.Scopes"
20
)
21

22
// Defines values for APIKeySummaryPermissionLevel.
23
const (
24
    APIKeySummaryPermissionLevelFull     APIKeySummaryPermissionLevel = "full"
25
    APIKeySummaryPermissionLevelReadonly APIKeySummaryPermissionLevel = "readonly"
26
)
27

28
// Defines values for APIKeyTestResponsePermissionLevel.
29
const (
30
    APIKeyTestResponsePermissionLevelFull     APIKeyTestResponsePermissionLevel = "full"
31
    APIKeyTestResponsePermissionLevelReadonly APIKeyTestResponsePermissionLevel = "readonly"
32
)
33

34
// Defines values for ChatMessageRole.
35
const (
36
    ChatMessageRoleAssistant ChatMessageRole = "assistant"
37
    ChatMessageRoleUser      ChatMessageRole = "user"
38
)
39

40
// Defines values for CreateAPIKeyRequestPermissionLevel.
41
const (
42
    CreateAPIKeyRequestPermissionLevelFull     CreateAPIKeyRequestPermissionLevel = "full"
43
    CreateAPIKeyRequestPermissionLevelReadonly CreateAPIKeyRequestPermissionLevel = "readonly"
44
)
45

46
// Defines values for CreateAPIKeyResponsePermissionLevel.
47
const (
48
    CreateAPIKeyResponsePermissionLevelFull     CreateAPIKeyResponsePermissionLevel = "full"
49
    CreateAPIKeyResponsePermissionLevelReadonly CreateAPIKeyResponsePermissionLevel = "readonly"
50
)
51

52
// Defines values for CreateMessageRequestRole.
53
const (
54
    CreateMessageRequestRoleAssistant CreateMessageRequestRole = "assistant"
55
    CreateMessageRequestRoleUser      CreateMessageRequestRole = "user"
56
)
57

58
// Defines values for CreateStoryRequestSectionLengthOverride.
59
const (
60
    CreateStoryRequestSectionLengthOverrideLong   CreateStoryRequestSectionLengthOverride = "long"
61
    CreateStoryRequestSectionLengthOverrideMedium CreateStoryRequestSectionLengthOverride = "medium"
62
    CreateStoryRequestSectionLengthOverrideShort  CreateStoryRequestSectionLengthOverride = "short"
63
)
64

65
// Defines values for ErrorResponseSeverity.
66
const (
67
    ErrorResponseSeverityError ErrorResponseSeverity = "error"
68
    ErrorResponseSeverityFatal ErrorResponseSeverity = "fatal"
69
    ErrorResponseSeverityInfo  ErrorResponseSeverity = "info"
70
    ErrorResponseSeverityWarn  ErrorResponseSeverity = "warn"
71
)
72

73
// Defines values for FeedbackReportFeedbackType.
74
const (
75
    FeedbackReportFeedbackTypeBug            FeedbackReportFeedbackType = "bug"
76
    FeedbackReportFeedbackTypeFeatureRequest FeedbackReportFeedbackType = "feature_request"
77
    FeedbackReportFeedbackTypeGeneral        FeedbackReportFeedbackType = "general"
78
    FeedbackReportFeedbackTypeImprovement    FeedbackReportFeedbackType = "improvement"
79
)
80

81
// Defines values for FeedbackReportStatus.
82
const (
83
    FeedbackReportStatusDismissed  FeedbackReportStatus = "dismissed"
84
    FeedbackReportStatusInProgress FeedbackReportStatus = "in_progress"
85
    FeedbackReportStatusNew        FeedbackReportStatus = "new"
86
    FeedbackReportStatusResolved   FeedbackReportStatus = "resolved"
87
)
88

89
// Defines values for FeedbackSubmissionRequestFeedbackType.
90
const (
91
    FeedbackSubmissionRequestFeedbackTypeBug            FeedbackSubmissionRequestFeedbackType = "bug"
92
    FeedbackSubmissionRequestFeedbackTypeFeatureRequest FeedbackSubmissionRequestFeedbackType = "feature_request"
93
    FeedbackSubmissionRequestFeedbackTypeGeneral        FeedbackSubmissionRequestFeedbackType = "general"
94
    FeedbackSubmissionRequestFeedbackTypeImprovement    FeedbackSubmissionRequestFeedbackType = "improvement"
95
)
96

97
// Defines values for FeedbackUpdateRequestStatus.
98
const (
99
    FeedbackUpdateRequestStatusDismissed  FeedbackUpdateRequestStatus = "dismissed"
100
    FeedbackUpdateRequestStatusInProgress FeedbackUpdateRequestStatus = "in_progress"
101
    FeedbackUpdateRequestStatusNew        FeedbackUpdateRequestStatus = "new"
102
    FeedbackUpdateRequestStatusResolved   FeedbackUpdateRequestStatus = "resolved"
103
)
104

105
// Defines values for NotificationErrorErrorType.
106
const (
107
    NotificationErrorErrorTypeEmailDisabled NotificationErrorErrorType = "email_disabled"
108
    NotificationErrorErrorTypeOther         NotificationErrorErrorType = "other"
109
    NotificationErrorErrorTypeSmtpError     NotificationErrorErrorType = "smtp_error"
110
    NotificationErrorErrorTypeTemplateError NotificationErrorErrorType = "template_error"
111
    NotificationErrorErrorTypeUserNotFound  NotificationErrorErrorType = "user_not_found"
112
)
113

114
// Defines values for NotificationErrorNotificationType.
115
const (
116
    NotificationErrorNotificationTypeDailyReminder NotificationErrorNotificationType = "daily_reminder"
117
    NotificationErrorNotificationTypeTestEmail     NotificationErrorNotificationType = "test_email"
118
)
119

120
// Defines values for QuestionStatus.
121
const (
122
    QuestionStatusActive   QuestionStatus = "active"
123
    QuestionStatusReported QuestionStatus = "reported"
124
)
125

126
// Defines values for QuestionType.
127
const (
128
    FillBlank            QuestionType = "fill_blank"
129
    Qa                   QuestionType = "qa"
130
    ReadingComprehension QuestionType = "reading_comprehension"
131
    Vocabulary           QuestionType = "vocabulary"
132
)
133

134
// Defines values for SentNotificationNotificationType.
135
const (
136
    SentNotificationNotificationTypeDailyReminder SentNotificationNotificationType = "daily_reminder"
137
    SentNotificationNotificationTypeTestEmail     SentNotificationNotificationType = "test_email"
138
)
139

140
// Defines values for SentNotificationStatus.
141
const (
142
    SentNotificationStatusBounced SentNotificationStatus = "bounced"
143
    SentNotificationStatusFailed  SentNotificationStatus = "failed"
144
    SentNotificationStatusSent    SentNotificationStatus = "sent"
145
)
146

147
// Defines values for StorySectionLengthOverride.
148
const (
149
    StorySectionLengthOverrideLong   StorySectionLengthOverride = "long"
150
    StorySectionLengthOverrideMedium StorySectionLengthOverride = "medium"
151
    StorySectionLengthOverrideShort  StorySectionLengthOverride = "short"
152
)
153

154
// Defines values for StoryStatus.
155
const (
156
    StoryStatusActive    StoryStatus = "active"
157
    StoryStatusArchived  StoryStatus = "archived"
158
    StoryStatusCompleted StoryStatus = "completed"
159
)
160

161
// Defines values for StoryWithSectionsSectionLengthOverride.
162
const (
163
    Long   StoryWithSectionsSectionLengthOverride = "long"
164
    Medium StoryWithSectionsSectionLengthOverride = "medium"
165
    Short  StoryWithSectionsSectionLengthOverride = "short"
166
)
167

168
// Defines values for StoryWithSectionsStatus.
169
const (
170
    Active    StoryWithSectionsStatus = "active"
171
    Archived  StoryWithSectionsStatus = "archived"
172
    Completed StoryWithSectionsStatus = "completed"
173
)
174

175
// Defines values for TTSRequestStreamFormat.
176
const (
177
    Audio       TTSRequestStreamFormat = "audio"
178
    AudioStream TTSRequestStreamFormat = "audio_stream"
179
    Sse         TTSRequestStreamFormat = "sse"
180
)
181

182
// Defines values for TTSResponseType.
183
const (
184
    TTSResponseTypeAudio TTSResponseType = "audio"
185
    TTSResponseTypeError TTSResponseType = "error"
186
    TTSResponseTypeUsage TTSResponseType = "usage"
187
)
188

189
// Defines values for WordOfTheDayDisplaySourceType.
190
const (
191
    WordOfTheDayDisplaySourceTypeSnippet            WordOfTheDayDisplaySourceType = "snippet"
192
    WordOfTheDayDisplaySourceTypeVocabularyQuestion WordOfTheDayDisplaySourceType = "vocabulary_question"
193
)
194

195
// Defines values for WorkerStatusStatus.
196
const (
197
    Busy  WorkerStatusStatus = "busy"
198
    Error WorkerStatusStatus = "error"
199
    Idle  WorkerStatusStatus = "idle"
200
)
201

202
// Defines values for DeleteV1AdminBackendFeedbackParamsStatus.
203
const (
204
    DeleteV1AdminBackendFeedbackParamsStatusDismissed  DeleteV1AdminBackendFeedbackParamsStatus = "dismissed"
205
    DeleteV1AdminBackendFeedbackParamsStatusInProgress DeleteV1AdminBackendFeedbackParamsStatus = "in_progress"
206
    DeleteV1AdminBackendFeedbackParamsStatusNew        DeleteV1AdminBackendFeedbackParamsStatus = "new"
207
    DeleteV1AdminBackendFeedbackParamsStatusResolved   DeleteV1AdminBackendFeedbackParamsStatus = "resolved"
208
)
209

210
// Defines values for GetV1AdminBackendFeedbackParamsStatus.
211
const (
212
    GetV1AdminBackendFeedbackParamsStatusDismissed  GetV1AdminBackendFeedbackParamsStatus = "dismissed"
213
    GetV1AdminBackendFeedbackParamsStatusInProgress GetV1AdminBackendFeedbackParamsStatus = "in_progress"
214
    GetV1AdminBackendFeedbackParamsStatusNew        GetV1AdminBackendFeedbackParamsStatus = "new"
215
    GetV1AdminBackendFeedbackParamsStatusResolved   GetV1AdminBackendFeedbackParamsStatus = "resolved"
216
)
217

218
// Defines values for GetV1AdminBackendUserzPaginatedParamsAiEnabled.
219
const (
220
    GetV1AdminBackendUserzPaginatedParamsAiEnabledFalse GetV1AdminBackendUserzPaginatedParamsAiEnabled = "false"
221
    GetV1AdminBackendUserzPaginatedParamsAiEnabledTrue  GetV1AdminBackendUserzPaginatedParamsAiEnabled = "true"
222
)
223

224
// Defines values for GetV1AdminBackendUserzPaginatedParamsActive.
225
const (
226
    GetV1AdminBackendUserzPaginatedParamsActiveFalse GetV1AdminBackendUserzPaginatedParamsActive = "false"
227
    GetV1AdminBackendUserzPaginatedParamsActiveTrue  GetV1AdminBackendUserzPaginatedParamsActive = "true"
228
)
229

230
// Defines values for GetV1AdminWorkerNotificationsErrorsParamsErrorType.
231
const (
232
    GetV1AdminWorkerNotificationsErrorsParamsErrorTypeEmailDisabled GetV1AdminWorkerNotificationsErrorsParamsErrorType = "email_disabled"
233
    GetV1AdminWorkerNotificationsErrorsParamsErrorTypeOther         GetV1AdminWorkerNotificationsErrorsParamsErrorType = "other"
234
    GetV1AdminWorkerNotificationsErrorsParamsErrorTypeSmtpError     GetV1AdminWorkerNotificationsErrorsParamsErrorType = "smtp_error"
235
    GetV1AdminWorkerNotificationsErrorsParamsErrorTypeTemplateError GetV1AdminWorkerNotificationsErrorsParamsErrorType = "template_error"
236
    GetV1AdminWorkerNotificationsErrorsParamsErrorTypeUserNotFound  GetV1AdminWorkerNotificationsErrorsParamsErrorType = "user_not_found"
237
)
238

239
// Defines values for GetV1AdminWorkerNotificationsErrorsParamsNotificationType.
240
const (
241
    GetV1AdminWorkerNotificationsErrorsParamsNotificationTypeDailyReminder GetV1AdminWorkerNotificationsErrorsParamsNotificationType = "daily_reminder"
242
    GetV1AdminWorkerNotificationsErrorsParamsNotificationTypeTestEmail     GetV1AdminWorkerNotificationsErrorsParamsNotificationType = "test_email"
243
)
244

245
// Defines values for GetV1AdminWorkerNotificationsErrorsParamsResolved.
246
const (
247
    False GetV1AdminWorkerNotificationsErrorsParamsResolved = "false"
248
    True  GetV1AdminWorkerNotificationsErrorsParamsResolved = "true"
249
)
250

251
// Defines values for GetV1AdminWorkerNotificationsSentParamsNotificationType.
252
const (
253
    GetV1AdminWorkerNotificationsSentParamsNotificationTypeDailyReminder GetV1AdminWorkerNotificationsSentParamsNotificationType = "daily_reminder"
254
    GetV1AdminWorkerNotificationsSentParamsNotificationTypeTestEmail     GetV1AdminWorkerNotificationsSentParamsNotificationType = "test_email"
255
)
256

257
// Defines values for GetV1AdminWorkerNotificationsSentParamsStatus.
258
const (
259
    GetV1AdminWorkerNotificationsSentParamsStatusBounced GetV1AdminWorkerNotificationsSentParamsStatus = "bounced"
260
    GetV1AdminWorkerNotificationsSentParamsStatusFailed  GetV1AdminWorkerNotificationsSentParamsStatus = "failed"
261
    GetV1AdminWorkerNotificationsSentParamsStatusSent    GetV1AdminWorkerNotificationsSentParamsStatus = "sent"
262
)
263

264
// Defines values for GetV1SnippetsParamsLevel.
265
const (
266
    A1 GetV1SnippetsParamsLevel = "A1"
267
    A2 GetV1SnippetsParamsLevel = "A2"
268
    B1 GetV1SnippetsParamsLevel = "B1"
269
    B2 GetV1SnippetsParamsLevel = "B2"
270
    C1 GetV1SnippetsParamsLevel = "C1"
271
    C2 GetV1SnippetsParamsLevel = "C2"
272
)
273

274
// AIConcurrencyStats defines model for AIConcurrencyStats.
275
type AIConcurrencyStats struct {
276
    ActiveRequests  *int            `json:"active_requests,omitempty"`
277
    MaxConcurrent   *int            `json:"max_concurrent,omitempty"`
278
    MaxPerUser      *int            `json:"max_per_user,omitempty"`
279
    QueuedRequests  *int            `json:"queued_requests,omitempty"`
280
    TotalRequests   *int            `json:"total_requests,omitempty"`
281
    UserActiveCount *map[string]int `json:"user_active_count,omitempty"`
282
}
283

284
// AIProviders defines model for AIProviders.
285
type AIProviders struct {
286
    Levels    *[]string `json:"levels,omitempty"`
287
    Providers *[]struct {
288
        Code   *string `json:"code,omitempty"`
289
        Models *[]struct {
290
            Code *string `json:"code,omitempty"`
291
            Name *string `json:"name,omitempty"`
292
        } `json:"models,omitempty"`
293
        Name *string `json:"name,omitempty"`
294
        Url  *string `json:"url,omitempty"`
295

296
        // UsageSupported Whether the provider supports usage tracking in streaming responses
297
        UsageSupported *bool `json:"usage_supported,omitempty"`
298
    } `json:"providers,omitempty"`
299
}
300

301
// APIKeyAvailabilityResponse defines model for APIKeyAvailabilityResponse.
302
type APIKeyAvailabilityResponse struct {
303
    // HasApiKey Whether the user has a saved API key for this provider
304
    HasApiKey bool `json:"has_api_key"`
305
}
306

307
// APIKeySummary defines model for APIKeySummary.
308
type APIKeySummary struct {
309
    // CreatedAt Creation timestamp
310
    CreatedAt *time.Time `json:"created_at,omitempty"`
311

312
    // Id Unique ID
313
    Id *int `json:"id,omitempty"`
314

315
    // KeyName Name of the key
316
    KeyName *string `json:"key_name,omitempty"`
317

318
    // KeyPrefix First characters for identification
319
    KeyPrefix *string `json:"key_prefix,omitempty"`
320

321
    // LastUsedAt Last time this key was used
322
    LastUsedAt *time.Time `json:"last_used_at"`
323

324
    // PermissionLevel Permission level
325
    PermissionLevel *APIKeySummaryPermissionLevel `json:"permission_level,omitempty"`
326

327
    // UpdatedAt Last update timestamp
328
    UpdatedAt *time.Time `json:"updated_at,omitempty"`
329
}
330

331
// APIKeySummaryPermissionLevel Permission level
332
type APIKeySummaryPermissionLevel string
333

334
// APIKeyTestResponse defines model for APIKeyTestResponse.
335
type APIKeyTestResponse struct {
336
    ApiKeyId        *int                               `json:"api_key_id,omitempty"`
337
    Method          *string                            `json:"method,omitempty"`
338
    Ok              *bool                              `json:"ok,omitempty"`
339
    PermissionLevel *APIKeyTestResponsePermissionLevel `json:"permission_level,omitempty"`
340
    UserId          *int                               `json:"user_id,omitempty"`
341
    Username        *string                            `json:"username,omitempty"`
342
}
343

344
// APIKeyTestResponsePermissionLevel defines model for APIKeyTestResponse.PermissionLevel.
345
type APIKeyTestResponsePermissionLevel string
346

347
// APIKeysListResponse defines model for APIKeysListResponse.
348
type APIKeysListResponse struct {
349
    ApiKeys *[]APIKeySummary `json:"api_keys,omitempty"`
350

351
    // Count Total number of keys
352
    Count *int `json:"count,omitempty"`
353
}
354

355
// AggregatedVersion defines model for AggregatedVersion.
356
type AggregatedVersion struct {
357
    Backend ServiceVersion           `json:"backend"`
358
    Worker  AggregatedVersion_Worker `json:"worker"`
359
}
360

361
// AggregatedVersionWorker1 defines model for .
362
type AggregatedVersionWorker1 struct {
363
    // Error Error message when worker is unavailable
364
    Error string `json:"error"`
365
}
366

367
// AggregatedVersion_Worker defines model for AggregatedVersion.Worker.
368
type AggregatedVersion_Worker struct {
369
    union json.RawMessage
370
}
371

372
// AnswerRequest defines model for AnswerRequest.
373
type AnswerRequest struct {
374
    // QuestionId ID of the question being answered
375
    QuestionId int64 `json:"question_id"`
376

377
    // ResponseTimeMs Response time in milliseconds (0-5 minutes)
378
    ResponseTimeMs *int32 `json:"response_time_ms,omitempty"`
379

380
    // UserAnswerIndex Index of the user's selected answer in the original options array (0-based)
381
    UserAnswerIndex int `json:"user_answer_index"`
382
}
383

384
// AnswerResponse defines model for AnswerResponse.
385
type AnswerResponse struct {
386
    // CorrectAnswerIndex Index of the correct answer in the options array (0-based)
387
    CorrectAnswerIndex *int    `json:"correct_answer_index,omitempty"`
388
    Explanation        *string `json:"explanation,omitempty"`
389
    IsCorrect          *bool   `json:"is_correct,omitempty"`
390
    NextDifficulty     *string `json:"next_difficulty,omitempty"`
391

392
    // UserAnswer The answer selected by the user
393
    UserAnswer *string `json:"user_answer,omitempty"`
394

395
    // UserAnswerIndex Index of the user's selected answer in the original options array (0-based)
396
    UserAnswerIndex *int `json:"user_answer_index,omitempty"`
397
}
398

399
// AuthStatusResponse defines model for AuthStatusResponse.
400
type AuthStatusResponse struct {
401
    // Authenticated Whether the user is currently authenticated
402
    Authenticated bool `json:"authenticated"`
403
    User          User `json:"user"`
404
}
405

406
// ChatMessage defines model for ChatMessage.
407
type ChatMessage struct {
408
    // Bookmarked Whether this message is bookmarked
409
    Bookmarked *bool `json:"bookmarked,omitempty"`
410

411
    // Content Message content
412
    Content struct {
413
        // Text The actual message text
414
        Text *string `json:"text,omitempty"`
415
    } `json:"content"`
416

417
    // ConversationId ID of the conversation this message belongs to
418
    ConversationId openapi_types.UUID `json:"conversation_id"`
419

420
    // ConversationTitle Title of the conversation (optional, included in search results)
421
    ConversationTitle *string `json:"conversation_title,omitempty"`
422

423
    // CreatedAt When the message was created
424
    CreatedAt time.Time `json:"created_at"`
425

426
    // Id Message UUID
427
    Id openapi_types.UUID `json:"id"`
428

429
    // QuestionId Optional question ID if this message relates to a specific question
430
    QuestionId *int `json:"question_id,omitempty"`
431

432
    // Role Role of the message sender
433
    Role ChatMessageRole `json:"role"`
434

435
    // UpdatedAt When the message was last updated
436
    UpdatedAt time.Time `json:"updated_at"`
437
}
438

439
// ChatMessageRole Role of the message sender
440
type ChatMessageRole string
441

442
// Conversation defines model for Conversation.
443
type Conversation struct {
444
    // CreatedAt When the conversation was created
445
    CreatedAt time.Time `json:"created_at"`
446

447
    // Id Conversation UUID
448
    Id openapi_types.UUID `json:"id"`
449

450
    // MessageCount Total number of messages in this conversation
451
    MessageCount *int `json:"message_count,omitempty"`
452

453
    // Messages Array of messages in this conversation (optional, only included when requested)
454
    Messages *[]ChatMessage `json:"messages,omitempty"`
455

456
    // Title Conversation title
457
    Title string `json:"title"`
458

459
    // UpdatedAt When the conversation was last updated
460
    UpdatedAt time.Time `json:"updated_at"`
461

462
    // UserId ID of the user who owns this conversation
463
    UserId int `json:"user_id"`
464
}
465

466
// CreateAPIKeyRequest defines model for CreateAPIKeyRequest.
467
type CreateAPIKeyRequest struct {
468
    // KeyName A descriptive name for the API key
469
    KeyName string `json:"key_name"`
470

471
    // PermissionLevel Permission level: 'readonly' for GET requests only, 'full' for all operations
472
    PermissionLevel CreateAPIKeyRequestPermissionLevel `json:"permission_level"`
473
}
474

475
// CreateAPIKeyRequestPermissionLevel Permission level: 'readonly' for GET requests only, 'full' for all operations
476
type CreateAPIKeyRequestPermissionLevel string
477

478
// CreateAPIKeyResponse defines model for CreateAPIKeyResponse.
479
type CreateAPIKeyResponse struct {
480
    // CreatedAt Creation timestamp
481
    CreatedAt *time.Time `json:"created_at,omitempty"`
482

483
    // Id Unique ID of the API key
484
    Id *int `json:"id,omitempty"`
485

486
    // Key Full API key - only shown once!
487
    Key *string `json:"key,omitempty"`
488

489
    // KeyName Name of the API key
490
    KeyName *string `json:"key_name,omitempty"`
491

492
    // KeyPrefix First characters of key for identification
493
    KeyPrefix *string `json:"key_prefix,omitempty"`
494

495
    // Message Warning message
496
    Message *string `json:"message,omitempty"`
497

498
    // PermissionLevel Permission level
499
    PermissionLevel *CreateAPIKeyResponsePermissionLevel `json:"permission_level,omitempty"`
500
}
501

502
// CreateAPIKeyResponsePermissionLevel Permission level
503
type CreateAPIKeyResponsePermissionLevel string
504

505
// CreateConversationRequest defines model for CreateConversationRequest.
506
type CreateConversationRequest struct {
507
    // Title Title for the conversation
508
    Title string `json:"title"`
509
}
510

511
// CreateLinearIssueResponse defines model for CreateLinearIssueResponse.
512
type CreateLinearIssueResponse struct {
513
    // IssueId The Linear issue ID
514
    IssueId string `json:"issue_id"`
515

516
    // IssueUrl URL to the created Linear issue
517
    IssueUrl string `json:"issue_url"`
518

519
    // Title The title of the created Linear issue
520
    Title string `json:"title"`
521
}
522

523
// CreateMessageRequest defines model for CreateMessageRequest.
524
type CreateMessageRequest struct {
525
    // Content Message content
526
    Content struct {
527
        // Text The actual message text
528
        Text *string `json:"text,omitempty"`
529
    } `json:"content"`
530

531
    // QuestionId Optional question ID if this message relates to a specific question
532
    QuestionId *int `json:"question_id,omitempty"`
533

534
    // Role Role of the message sender
535
    Role CreateMessageRequestRole `json:"role"`
536
}
537

538
// CreateMessageRequestRole Role of the message sender
539
type CreateMessageRequestRole string
540

541
// CreateSnippetRequest defines model for CreateSnippetRequest.
542
type CreateSnippetRequest struct {
543
    // Context Optional user-provided context or notes about this snippet
544
    Context *string `json:"context"`
545

546
    // OriginalText The original text/word to save
547
    OriginalText string `json:"original_text"`
548

549
    // QuestionId Optional ID of the question where this text was encountered. If provided, the snippet will inherit the question's difficulty level (A1, A2, B1, B2, C1, C2)
550
    QuestionId *int64 `json:"question_id"`
551

552
    // SectionId Optional ID of the story section where this text was encountered
553
    SectionId *int64 `json:"section_id"`
554

555
    // SourceLanguage ISO language code of the source text
556
    SourceLanguage string `json:"source_language"`
557

558
    // StoryId Optional ID of the story where this text was encountered
559
    StoryId *int64 `json:"story_id"`
560

561
    // TargetLanguage ISO language code of the target translation
562
    TargetLanguage string `json:"target_language"`
563

564
    // TranslatedText The translated text
565
    TranslatedText string `json:"translated_text"`
566
}
567

568
// CreateStoryRequest defines model for CreateStoryRequest.
569
type CreateStoryRequest struct {
570
    AuthorStyle           *string                                  `json:"author_style"`
571
    CharacterNames        *string                                  `json:"character_names"`
572
    CustomInstructions    *string                                  `json:"custom_instructions"`
573
    Genre                 *string                                  `json:"genre"`
574
    SectionLengthOverride *CreateStoryRequestSectionLengthOverride `json:"section_length_override,omitempty"`
575
    Subject               *string                                  `json:"subject"`
576
    TimePeriod            *string                                  `json:"time_period"`
577
    Title                 string                                   `json:"title"`
578
    Tone                  *string                                  `json:"tone"`
579
}
580

581
// CreateStoryRequestSectionLengthOverride defines model for CreateStoryRequest.SectionLengthOverride.
582
type CreateStoryRequestSectionLengthOverride string
583

584
// DailyProgress defines model for DailyProgress.
585
type DailyProgress struct {
586
    // Completed Number of completed questions
587
    Completed int `json:"completed"`
588

589
    // Date Date for the progress report (YYYY-MM-DD)
590
    Date openapi_types.Date `json:"date"`
591

592
    // Total Total number of questions assigned for the date
593
    Total int `json:"total"`
594
}
595

596
// DailyQuestionHistory defines model for DailyQuestionHistory.
597
type DailyQuestionHistory struct {
598
    // AssignmentDate RFC3339 timestamp of when the question was assigned in the user's timezone (includes offset)
599
    AssignmentDate string `json:"assignment_date"`
600

601
    // IsCompleted Whether the question was completed on this date
602
    IsCompleted bool `json:"is_completed"`
603

604
    // IsCorrect Whether the user's answer was correct (null if not attempted)
605
    IsCorrect *bool `json:"is_correct"`
606

607
    // SubmittedAt When the user submitted their answer
608
    SubmittedAt *string `json:"submitted_at"`
609
}
610

611
// DailyQuestionWithDetails defines model for DailyQuestionWithDetails.
612
type DailyQuestionWithDetails struct {
613
    // AssignmentDate Date-only assignment (YYYY-MM-DD) representing the logical calendar day the question was assigned (no timezone offset)
614
    AssignmentDate openapi_types.Date `json:"assignment_date"`
615

616
    // CompletedAt When the question was completed (if completed)
617
    CompletedAt *string `json:"completed_at"`
618

619
    // CreatedAt When the assignment was created
620
    CreatedAt string `json:"created_at"`
621

622
    // Id Daily question assignment ID
623
    Id int64 `json:"id"`
624

625
    // IsCompleted Whether the question has been completed
626
    IsCompleted bool     `json:"is_completed"`
627
    Question    Question `json:"question"`
628

629
    // QuestionId Question ID
630
    QuestionId int64 `json:"question_id"`
631

632
    // SubmittedAt When the user submitted their answer
633
    SubmittedAt *string `json:"submitted_at"`
634

635
    // UserAnswerIndex The index of the answer option the user selected (0-based)
636
    UserAnswerIndex *int `json:"user_answer_index"`
637

638
    // UserCorrectCount Number of times this user answered this question correctly
639
    UserCorrectCount *int64 `json:"user_correct_count,omitempty"`
640

641
    // UserId User ID
642
    UserId int64 `json:"user_id"`
643

644
    // UserIncorrectCount Number of times this user answered this question incorrectly
645
    UserIncorrectCount *int64 `json:"user_incorrect_count,omitempty"`
646

647
    // UserShownCount Number of times this question was shown to this user in Daily view
648
    UserShownCount *int64 `json:"user_shown_count,omitempty"`
649

650
    // UserTotalResponses Number of times this user answered this question
651
    UserTotalResponses *int64 `json:"user_total_responses,omitempty"`
652
}
653

654
// DashboardResponse defines model for DashboardResponse.
655
type DashboardResponse struct {
656
    AiConcurrencyStats *AIConcurrencyStats `json:"ai_concurrency_stats,omitempty"`
657
    QuestionStats      *QuestionStats      `json:"question_stats,omitempty"`
658
    Users              *[]DashboardUser    `json:"users,omitempty"`
659
    WorkerBaseUrl      *string             `json:"worker_base_url,omitempty"`
660
    WorkerHealth       *WorkerHealth       `json:"worker_health,omitempty"`
661
    WorkerPort         *string             `json:"worker_port,omitempty"`
662
}
663

664
// DashboardUser defines model for DashboardUser.
665
type DashboardUser struct {
666
    Progress      *UserProgress      `json:"progress,omitempty"`
667
    QuestionStats *UserQuestionStats `json:"question_stats,omitempty"`
668
    User          *UserProfile       `json:"user,omitempty"`
669
}
670

671
// DeleteAPIKeyResponse defines model for DeleteAPIKeyResponse.
672
type DeleteAPIKeyResponse struct {
673
    Message *string `json:"message,omitempty"`
674
    Success *bool   `json:"success,omitempty"`
675
}
676

677
// EmptyRequest Empty request body for endpoints that don't require request data
678
type EmptyRequest = map[string]interface{}
679

680
// ErrorResponse defines model for ErrorResponse.
681
type ErrorResponse struct {
682
    // Code Error code identifying the type of error
683
    Code *string `json:"code,omitempty"`
684

685
    // Details Additional error details
686
    Details *string `json:"details,omitempty"`
687

688
    // Error Error message (for backward compatibility)
689
    Error *string `json:"error,omitempty"`
690

691
    // Message Human-readable error message
692
    Message *string `json:"message,omitempty"`
693

694
    // Retryable Whether the operation can be retried
695
    Retryable *bool `json:"retryable,omitempty"`
696

697
    // Severity Severity level of the error
698
    Severity *ErrorResponseSeverity `json:"severity,omitempty"`
699
}
700

701
// ErrorResponseSeverity Severity level of the error
702
type ErrorResponseSeverity string
703

704
// FeedbackListResponse defines model for FeedbackListResponse.
705
type FeedbackListResponse struct {
706
    // Items List of feedback reports
707
    Items []FeedbackReport `json:"items"`
708

709
    // Page Current page number
710
    Page int `json:"page"`
711

712
    // PageSize Number of items per page
713
    PageSize int `json:"page_size"`
714

715
    // Total Total number of feedback reports matching filters
716
    Total int `json:"total"`
717
}
718

719
// FeedbackReport defines model for FeedbackReport.
720
type FeedbackReport struct {
721
    // AdminNotes Notes from admin
722
    AdminNotes *string `json:"admin_notes"`
723

724
    // AssignedToUserId User ID assigned to handle this feedback
725
    AssignedToUserId *int64 `json:"assigned_to_user_id"`
726

727
    // ContextData Context metadata as JSON object
728
    ContextData *map[string]interface{} `json:"context_data,omitempty"`
729

730
    // CreatedAt When the feedback was created
731
    CreatedAt time.Time `json:"created_at"`
732

733
    // FeedbackText Feedback or issue description
734
    FeedbackText string `json:"feedback_text"`
735

736
    // FeedbackType Type of feedback
737
    FeedbackType FeedbackReportFeedbackType `json:"feedback_type"`
738

739
    // Id Feedback report ID
740
    Id int64 `json:"id"`
741

742
    // ResolvedAt When the feedback was resolved
743
    ResolvedAt *time.Time `json:"resolved_at"`
744

745
    // ResolvedByUserId User ID who resolved the feedback
746
    ResolvedByUserId *int64 `json:"resolved_by_user_id"`
747

748
    // ScreenshotData Base64 encoded screenshot
749
    ScreenshotData *string `json:"screenshot_data"`
750

751
    // ScreenshotUrl URL to stored screenshot file
752
    ScreenshotUrl *string `json:"screenshot_url"`
753

754
    // Status Current status of the feedback
755
    Status FeedbackReportStatus `json:"status"`
756

757
    // UpdatedAt When the feedback was last updated
758
    UpdatedAt time.Time `json:"updated_at"`
759

760
    // UserId User ID who submitted the feedback
761
    UserId int64 `json:"user_id"`
762
}
763

764
// FeedbackReportFeedbackType Type of feedback
765
type FeedbackReportFeedbackType string
766

767
// FeedbackReportStatus Current status of the feedback
768
type FeedbackReportStatus string
769

770
// FeedbackSubmissionRequest defines model for FeedbackSubmissionRequest.
771
type FeedbackSubmissionRequest struct {
772
    // ContextData Context metadata as JSON object
773
    ContextData *map[string]interface{} `json:"context_data,omitempty"`
774

775
    // FeedbackText Feedback or issue description
776
    FeedbackText string `json:"feedback_text"`
777

778
    // FeedbackType Type of feedback
779
    FeedbackType *FeedbackSubmissionRequestFeedbackType `json:"feedback_type,omitempty"`
780

781
    // ScreenshotData Base64 encoded screenshot (optional)
782
    ScreenshotData *[]byte `json:"screenshot_data,omitempty"`
783
}
784

785
// FeedbackSubmissionRequestFeedbackType Type of feedback
786
type FeedbackSubmissionRequestFeedbackType string
787

788
// FeedbackUpdateRequest defines model for FeedbackUpdateRequest.
789
type FeedbackUpdateRequest struct {
790
    // AdminNotes Admin notes about this feedback
791
    AdminNotes *string `json:"admin_notes,omitempty"`
792

793
    // AssignedToUserId User ID to assign this feedback to
794
    AssignedToUserId *int64 `json:"assigned_to_user_id,omitempty"`
795

796
    // ResolvedAt When the feedback was resolved (use current time if status is resolved)
797
    ResolvedAt *time.Time `json:"resolved_at,omitempty"`
798

799
    // ResolvedByUserId User ID who resolved the feedback
800
    ResolvedByUserId *int64 `json:"resolved_by_user_id,omitempty"`
801

802
    // Status New status for the feedback
803
    Status *FeedbackUpdateRequestStatus `json:"status,omitempty"`
804
}
805

806
// FeedbackUpdateRequestStatus New status for the feedback
807
type FeedbackUpdateRequestStatus string
808

809
// ForceSendNotificationResponse defines model for ForceSendNotificationResponse.
810
type ForceSendNotificationResponse struct {
811
    Message      *string `json:"message,omitempty"`
812
    Notification *struct {
813
        Status  *string `json:"status,omitempty"`
814
        Subject *string `json:"subject,omitempty"`
815
        Type    *string `json:"type,omitempty"`
816
    } `json:"notification,omitempty"`
817
    User *struct {
818
        Email    *string `json:"email,omitempty"`
819
        Id       *int64  `json:"id,omitempty"`
820
        Username *string `json:"username,omitempty"`
821
    } `json:"user,omitempty"`
822
}
823

824
// GeneratingResponse defines model for GeneratingResponse.
825
type GeneratingResponse struct {
826
    // AiModel User's preferred AI model
827
    AiModel *string `json:"ai_model,omitempty"`
828

829
    // ApiKey User's API key for the selected provider (write-only)
830
    ApiKey  *string `json:"api_key,omitempty"`
831
    Message *string `json:"message,omitempty"`
832
    Status  *string `json:"status,omitempty"`
833
}
834

835
// GenerationFocus defines model for GenerationFocus.
836
type GenerationFocus struct {
837
    // CurrentGenerationModel The AI model currently being used for generation
838
    CurrentGenerationModel *string `json:"current_generation_model,omitempty"`
839

840
    // GenerationRate Average number of questions generated per minute
841
    GenerationRate *float32 `json:"generation_rate,omitempty"`
842

843
    // LastGenerationTime Timestamp of the last time a question was generated
844
    LastGenerationTime *string `json:"last_generation_time,omitempty"`
845
}
846

847
// GenerationIntelligence defines model for GenerationIntelligence.
848
type GenerationIntelligence struct {
849
    GapAnalysis           *[]map[string]interface{} `json:"gapAnalysis,omitempty"`
850
    GenerationSuggestions *[]map[string]interface{} `json:"generationSuggestions,omitempty"`
851
}
852

853
// GoogleOAuthLoginResponse defines model for GoogleOAuthLoginResponse.
854
type GoogleOAuthLoginResponse struct {
855
    // AuthUrl The Google OAuth authorization URL to redirect the user to
856
    AuthUrl string `json:"auth_url"`
857
}
858

859
// Language Learning language (dynamic). Allowed values come from config.yaml language_levels keys.
860
type Language = string
861

862
// LanguageInfo defines model for LanguageInfo.
863
type LanguageInfo struct {
864
    // Code ISO language code
865
    Code string `json:"code"`
866

867
    // Name Human-readable language name
868
    Name string `json:"name"`
869

870
    // TtsLocale TTS locale code for this language
871
    TtsLocale *string `json:"tts_locale,omitempty"`
872

873
    // TtsVoice Default TTS voice for this language
874
    TtsVoice *string `json:"tts_voice,omitempty"`
875
}
876

877
// LanguagesResponse Array of available learning languages with codes and names
878
type LanguagesResponse = []LanguageInfo
879

880
// Level Proficiency level (dynamic). Allowed values depend on the selected language and are sourced from config.yaml (e.g., CEFR A1âC2, JLPT N5âN1, HSK1âHSK6).
881
type Level = string
882

883
// LevelsResponse defines model for LevelsResponse.
884
type LevelsResponse struct {
885
    // LevelDescriptions Mapping from level code to short label (e.g. Beginner, Intermediate)
886
    LevelDescriptions map[string]string `json:"level_descriptions"`
887

888
    // Levels Array of available language proficiency levels
889
    Levels []string `json:"levels"`
890
}
891

892
// LoginRequest defines model for LoginRequest.
893
type LoginRequest struct {
894
    // Password Password (minimum 8 characters)
895
    Password string `json:"password"`
896

897
    // Username Username (1-100 characters, alphanumeric + underscore + email characters, cannot be empty or whitespace-only)
898
    Username string `json:"username"`
899
}
900

901
// LoginResponse defines model for LoginResponse.
902
type LoginResponse struct {
903
    Message *string `json:"message,omitempty"`
904

905
    // RedirectUri Redirect URI for OAuth flows (optional)
906
    RedirectUri *string `json:"redirect_uri,omitempty"`
907
    Success     *bool   `json:"success,omitempty"`
908
    User        *User   `json:"user,omitempty"`
909
}
910

911
// MarkQuestionKnownRequest defines model for MarkQuestionKnownRequest.
912
type MarkQuestionKnownRequest struct {
913
    // ConfidenceLevel User's confidence level (1-5, optional)
914
    ConfidenceLevel *int `json:"confidence_level,omitempty"`
915
}
916

917
// NotificationError defines model for NotificationError.
918
type NotificationError struct {
919
    // EmailAddress Email address that was being used
920
    EmailAddress *string `json:"email_address"`
921

922
    // ErrorMessage Detailed error message
923
    ErrorMessage *string `json:"error_message,omitempty"`
924

925
    // ErrorType Type of error that occurred
926
    ErrorType *NotificationErrorErrorType `json:"error_type,omitempty"`
927
    Id        *int64                      `json:"id,omitempty"`
928

929
    // NotificationType Type of notification that failed
930
    NotificationType *NotificationErrorNotificationType `json:"notification_type,omitempty"`
931

932
    // OccurredAt When the error occurred
933
    OccurredAt *string `json:"occurred_at,omitempty"`
934

935
    // ResolutionNotes Notes about how the error was resolved
936
    ResolutionNotes *string `json:"resolution_notes"`
937

938
    // ResolvedAt When the error was resolved
939
    ResolvedAt *string `json:"resolved_at"`
940
    UserId     *int64  `json:"user_id"`
941

942
    // Username Username of the user (if available)
943
    Username *string `json:"username,omitempty"`
944
}
945

946
// NotificationErrorErrorType Type of error that occurred
947
type NotificationErrorErrorType string
948

949
// NotificationErrorNotificationType Type of notification that failed
950
type NotificationErrorNotificationType string
951

952
// NotificationErrorStats defines model for NotificationErrorStats.
953
type NotificationErrorStats struct {
954
    // ErrorsByNotificationType Breakdown of errors by notification type
955
    ErrorsByNotificationType *map[string]int `json:"errors_by_notification_type,omitempty"`
956

957
    // ErrorsByType Breakdown of errors by type
958
    ErrorsByType *map[string]int `json:"errors_by_type,omitempty"`
959

960
    // TotalErrors Total number of errors
961
    TotalErrors *int `json:"total_errors,omitempty"`
962

963
    // UnresolvedErrors Number of unresolved errors
964
    UnresolvedErrors *int `json:"unresolved_errors,omitempty"`
965
}
966

967
// NotificationStats defines model for NotificationStats.
968
type NotificationStats struct {
969
    // NotificationsByType Breakdown of notifications by type
970
    NotificationsByType *map[string]int `json:"notifications_by_type,omitempty"`
971

972
    // SentThisWeek Number of notifications sent this week
973
    SentThisWeek *int `json:"sent_this_week,omitempty"`
974

975
    // SentToday Number of notifications sent today
976
    SentToday *int `json:"sent_today,omitempty"`
977

978
    // SuccessRate Success rate as a percentage (0-1)
979
    SuccessRate *float32 `json:"success_rate,omitempty"`
980

981
    // TotalFailed Total number of notifications that failed
982
    TotalFailed *int `json:"total_failed,omitempty"`
983

984
    // TotalSent Total number of notifications sent
985
    TotalSent *int `json:"total_sent,omitempty"`
986
}
987

988
// PaginationInfo defines model for PaginationInfo.
989
type PaginationInfo struct {
990
    // Page Current page number
991
    Page int `json:"page"`
992

993
    // PageSize Number of items per page
994
    PageSize int `json:"page_size"`
995

996
    // Total Total number of items
997
    Total int `json:"total"`
998

999
    // TotalPages Total number of pages
1000
    TotalPages int `json:"total_pages"`
1001
}
1002

1003
// PasswordResetRequest defines model for PasswordResetRequest.
1004
type PasswordResetRequest struct {
1005
    // NewPassword New password (minimum 8 characters)
1006
    NewPassword string `json:"new_password"`
1007
}
1008

1009
// PerformanceMetrics defines model for PerformanceMetrics.
1010
type PerformanceMetrics struct {
1011
    AverageResponseTimeMs *float32 `json:"average_response_time_ms,omitempty"`
1012
    CorrectAttempts       *int     `json:"correct_attempts,omitempty"`
1013
    LastUpdated           *string  `json:"last_updated,omitempty"`
1014
    TotalAttempts         *int     `json:"total_attempts,omitempty"`
1015
}
1016

1017
// PriorityInsights defines model for PriorityInsights.
1018
type PriorityInsights struct {
1019
    // HighPriorityQuestions Number of high-priority questions
1020
    HighPriorityQuestions *int `json:"high_priority_questions,omitempty"`
1021

1022
    // LowPriorityQuestions Number of low-priority questions
1023
    LowPriorityQuestions *int `json:"low_priority_questions,omitempty"`
1024

1025
    // MediumPriorityQuestions Number of medium-priority questions
1026
    MediumPriorityQuestions *int `json:"medium_priority_questions,omitempty"`
1027

1028
    // TotalQuestionsInQueue Total number of questions waiting to be processed
1029
    TotalQuestionsInQueue *int `json:"total_questions_in_queue,omitempty"`
1030
}
1031

1032
// Question defines model for Question.
1033
type Question struct {
1034
    // ConfidenceLevel Confidence level when question was marked as known (1-5)
1035
    ConfidenceLevel *int `json:"confidence_level,omitempty"`
1036

1037
    // Content All question types now use multiple choice format with 4 options
1038
    Content *QuestionContent `json:"content,omitempty"`
1039

1040
    // CorrectAnswer Index of the correct answer in the options array (0-based)
1041
    CorrectAnswer *int `json:"correct_answer,omitempty"`
1042

1043
    // CorrectCount Number of times this question was answered correctly
1044
    CorrectCount *int    `json:"correct_count,omitempty"`
1045
    CreatedAt    *string `json:"created_at,omitempty"`
1046

1047
    // DifficultyModifier Difficulty modifier for the question (e.g., basic, intermediate)
1048
    DifficultyModifier *string  `json:"difficulty_modifier,omitempty"`
1049
    DifficultyScore    *float32 `json:"difficulty_score,omitempty"`
1050
    Explanation        *string  `json:"explanation,omitempty"`
1051

1052
    // GrammarFocus Grammar focus area for the question (e.g., present_perfect, conditionals)
1053
    GrammarFocus *string `json:"grammar_focus,omitempty"`
1054
    Id           *int64  `json:"id,omitempty"`
1055

1056
    // IncorrectCount Number of times this question was answered incorrectly
1057
    IncorrectCount *int `json:"incorrect_count,omitempty"`
1058

1059
    // Language Learning language (dynamic). Allowed values come from config.yaml language_levels keys.
1060
    Language *Language `json:"language,omitempty"`
1061

1062
    // Level Proficiency level (dynamic). Allowed values depend on the selected language and are sourced from config.yaml (e.g., CEFR A1âC2, JLPT N5âN1, HSK1âHSK6).
1063
    Level *Level `json:"level,omitempty"`
1064

1065
    // Reporters Comma-separated list of usernames who reported this question
1066
    Reporters *string `json:"reporters,omitempty"`
1067

1068
    // Scenario Scenario context for the question (e.g., at_the_airport, in_a_restaurant)
1069
    Scenario *string         `json:"scenario,omitempty"`
1070
    Status   *QuestionStatus `json:"status,omitempty"`
1071

1072
    // StyleModifier Style modifier for the question (e.g., conversational, formal)
1073
    StyleModifier *string `json:"style_modifier,omitempty"`
1074

1075
    // TimeContext Time context for the question (e.g., morning_routine, workday)
1076
    TimeContext *string `json:"time_context,omitempty"`
1077

1078
    // TopicCategory General topic category for question context (e.g., daily_life, travel, work)
1079
    TopicCategory *string `json:"topic_category,omitempty"`
1080

1081
    // TotalResponses Total number of responses to this question (used for 'Shown' in the UI)
1082
    TotalResponses *int          `json:"total_responses,omitempty"`
1083
    Type           *QuestionType `json:"type,omitempty"`
1084

1085
    // UserCount Number of users assigned to this question
1086
    UserCount *int `json:"user_count,omitempty"`
1087

1088
    // VocabularyDomain Vocabulary domain for the question (e.g., food_and_dining, transportation)
1089
    VocabularyDomain *string `json:"vocabulary_domain,omitempty"`
1090
}
1091

1092
// QuestionContent All question types now use multiple choice format with 4 options
1093
type QuestionContent struct {
1094
    // Hint Optional hint for fill-in-blank questions
1095
    Hint    *string  `json:"hint,omitempty"`
1096
    Options []string `json:"options"`
1097

1098
    // Passage Only present for reading comprehension questions
1099
    Passage  *string `json:"passage,omitempty"`
1100
    Question string  `json:"question"`
1101

1102
    // Sentence Only present for vocabulary questions (context sentence)
1103
    Sentence *string `json:"sentence,omitempty"`
1104
}
1105

1106
// QuestionStats defines model for QuestionStats.
1107
type QuestionStats struct {
1108
    // QuestionsByLanguage Breakdown of questions by language
1109
    QuestionsByLanguage *map[string]int `json:"questions_by_language,omitempty"`
1110

1111
    // QuestionsByLevel Breakdown of questions by level
1112
    QuestionsByLevel *map[string]int `json:"questions_by_level,omitempty"`
1113

1114
    // QuestionsByType Breakdown of questions by type
1115
    QuestionsByType *map[string]int `json:"questions_by_type,omitempty"`
1116

1117
    // TotalQuestions Total number of questions
1118
    TotalQuestions *int `json:"total_questions,omitempty"`
1119

1120
    // TotalResponses Total number of responses
1121
    TotalResponses *int `json:"total_responses,omitempty"`
1122
}
1123

1124
// QuestionStatus defines model for QuestionStatus.
1125
type QuestionStatus string
1126

1127
// QuestionType defines model for QuestionType.
1128
type QuestionType string
1129

1130
// QuizChatRequest defines model for QuizChatRequest.
1131
type QuizChatRequest struct {
1132
    AnswerContext *AnswerResponse `json:"answer_context,omitempty"`
1133

1134
    // ConversationHistory Previous messages in the conversation
1135
    ConversationHistory *[]ChatMessage `json:"conversation_history,omitempty"`
1136
    Question            Question       `json:"question"`
1137

1138
    // UserMessage The user's message to the AI tutor.
1139
    UserMessage string `json:"user_message"`
1140
}
1141

1142
// ReportQuestionRequest defines model for ReportQuestionRequest.
1143
type ReportQuestionRequest struct {
1144
    // ReportReason Optional explanation for why the question is being reported
1145
    ReportReason *string `json:"report_reason,omitempty"`
1146
}
1147

1148
// Role defines model for Role.
1149
type Role struct {
1150
    // CreatedAt When the role was created
1151
    CreatedAt string `json:"created_at"`
1152

1153
    // Description Role description
1154
    Description string `json:"description"`
1155

1156
    // Id Role ID
1157
    Id int64 `json:"id"`
1158

1159
    // Name Role name (e.g., "user", "admin")
1160
    Name string `json:"name"`
1161

1162
    // UpdatedAt When the role was last updated
1163
    UpdatedAt string `json:"updated_at"`
1164
}
1165

1166
// SentNotification defines model for SentNotification.
1167
type SentNotification struct {
1168
    // EmailAddress Email address the notification was sent to
1169
    EmailAddress *string `json:"email_address,omitempty"`
1170

1171
    // ErrorMessage Error message if the notification failed
1172
    ErrorMessage *string `json:"error_message"`
1173
    Id           *int64  `json:"id,omitempty"`
1174

1175
    // NotificationType Type of notification
1176
    NotificationType *SentNotificationNotificationType `json:"notification_type,omitempty"`
1177

1178
    // RetryCount Number of times the notification was retried
1179
    RetryCount *int `json:"retry_count,omitempty"`
1180

1181
    // SentAt When the notification was sent
1182
    SentAt *string `json:"sent_at,omitempty"`
1183

1184
    // Status Status of the notification
1185
    Status *SentNotificationStatus `json:"status,omitempty"`
1186

1187
    // Subject Subject line of the email
1188
    Subject *string `json:"subject,omitempty"`
1189

1190
    // TemplateName Template used for the notification
1191
    TemplateName *string `json:"template_name,omitempty"`
1192
    UserId       *int64  `json:"user_id,omitempty"`
1193

1194
    // Username Username of the user
1195
    Username *string `json:"username,omitempty"`
1196
}
1197

1198
// SentNotificationNotificationType Type of notification
1199
type SentNotificationNotificationType string
1200

1201
// SentNotificationStatus Status of the notification
1202
type SentNotificationStatus string
1203

1204
// ServiceUsageStatsResponse defines model for ServiceUsageStatsResponse.
1205
type ServiceUsageStatsResponse struct {
1206
    Data []struct {
1207
        // CharactersUsed Number of characters processed
1208
        CharactersUsed *int `json:"characters_used,omitempty"`
1209

1210
        // Month First day of the month (YYYY-MM)
1211
        Month *string `json:"month,omitempty"`
1212

1213
        // Quota Monthly quota for this service
1214
        Quota *int `json:"quota,omitempty"`
1215

1216
        // RequestsMade Number of requests made
1217
        RequestsMade *int `json:"requests_made,omitempty"`
1218

1219
        // UsageType Type of usage (e.g., "translation")
1220
        UsageType *string `json:"usage_type,omitempty"`
1221
    } `json:"data"`
1222

1223
    // Service Name of the service
1224
    Service string `json:"service"`
1225
}
1226

1227
// ServiceVersion defines model for ServiceVersion.
1228
type ServiceVersion struct {
1229
    // BuildTime Build timestamp (ISO8601)
1230
    BuildTime string `json:"buildTime"`
1231

1232
    // Commit Git commit hash
1233
    Commit string `json:"commit"`
1234

1235
    // Service Service name (e.g., 'backend', 'worker')
1236
    Service string `json:"service"`
1237

1238
    // Version Version string (e.g., git tag or 'dev')
1239
    Version string `json:"version"`
1240
}
1241

1242
// SignupStatusResponse defines model for SignupStatusResponse.
1243
type SignupStatusResponse struct {
1244
    // SignupsDisabled Whether user signups are currently disabled
1245
    SignupsDisabled bool `json:"signups_disabled"`
1246
}
1247

1248
// Snippet defines model for Snippet.
1249
type Snippet struct {
1250
    Context   *string    `json:"context"`
1251
    CreatedAt *time.Time `json:"created_at,omitempty"`
1252

1253
    // DifficultyLevel CEFR level (A1, A2, B1, B2, C1, C2)
1254
    DifficultyLevel *string `json:"difficulty_level"`
1255
    Id              *int64  `json:"id,omitempty"`
1256
    OriginalText    *string `json:"original_text,omitempty"`
1257
    QuestionId      *int64  `json:"question_id"`
1258

1259
    // SectionId ID of the story section where this snippet was created
1260
    SectionId      *int64  `json:"section_id"`
1261
    SourceLanguage *string `json:"source_language,omitempty"`
1262

1263
    // StoryId ID of the story where this snippet was created
1264
    StoryId        *int64     `json:"story_id"`
1265
    TargetLanguage *string    `json:"target_language,omitempty"`
1266
    TranslatedText *string    `json:"translated_text,omitempty"`
1267
    UpdatedAt      *time.Time `json:"updated_at,omitempty"`
1268
    UserId         *int64     `json:"user_id,omitempty"`
1269
}
1270

1271
// SnippetList defines model for SnippetList.
1272
type SnippetList struct {
1273
    // Limit Number of snippets returned
1274
    Limit *int `json:"limit,omitempty"`
1275

1276
    // Offset Number of snippets skipped
1277
    Offset *int `json:"offset,omitempty"`
1278

1279
    // Query The search query that was used (if any)
1280
    Query    *string    `json:"query"`
1281
    Snippets *[]Snippet `json:"snippets,omitempty"`
1282

1283
    // Total Total number of snippets matching the query
1284
    Total *int `json:"total,omitempty"`
1285
}
1286

1287
// Story defines model for Story.
1288
type Story struct {
1289
    AuthorStyle *string `json:"author_style"`
1290

1291
    // AutoGenerationPaused When true, the worker will skip automatic section generation for this story
1292
    AutoGenerationPaused   *bool                       `json:"auto_generation_paused,omitempty"`
1293
    CharacterNames         *string                     `json:"character_names"`
1294
    CreatedAt              *time.Time                  `json:"created_at,omitempty"`
1295
    CustomInstructions     *string                     `json:"custom_instructions"`
1296
    ExtraGenerationsToday  *int                        `json:"extra_generations_today,omitempty"`
1297
    Genre                  *string                     `json:"genre"`
1298
    Id                     *int64                      `json:"id,omitempty"`
1299
    Language               *string                     `json:"language,omitempty"`
1300
    LastSectionGeneratedAt *time.Time                  `json:"last_section_generated_at"`
1301
    SectionLengthOverride  *StorySectionLengthOverride `json:"section_length_override,omitempty"`
1302
    Status                 *StoryStatus                `json:"status,omitempty"`
1303
    Subject                *string                     `json:"subject"`
1304
    TimePeriod             *string                     `json:"time_period"`
1305
    Title                  *string                     `json:"title,omitempty"`
1306
    Tone                   *string                     `json:"tone"`
1307
    UpdatedAt              *time.Time                  `json:"updated_at,omitempty"`
1308
    UserId                 *int64                      `json:"user_id,omitempty"`
1309
}
1310

1311
// StorySectionLengthOverride defines model for Story.SectionLengthOverride.
1312
type StorySectionLengthOverride string
1313

1314
// StoryStatus defines model for Story.Status.
1315
type StoryStatus string
1316

1317
// StorySection defines model for StorySection.
1318
type StorySection struct {
1319
    Content        *string             `json:"content,omitempty"`
1320
    GeneratedAt    *time.Time          `json:"generated_at,omitempty"`
1321
    GenerationDate *openapi_types.Date `json:"generation_date,omitempty"`
1322
    Id             *int64              `json:"id,omitempty"`
1323
    LanguageLevel  *string             `json:"language_level,omitempty"`
1324
    SectionNumber  *int                `json:"section_number,omitempty"`
1325
    StoryId        *int64              `json:"story_id,omitempty"`
1326
    WordCount      *int                `json:"word_count,omitempty"`
1327
}
1328

1329
// StorySectionQuestion defines model for StorySectionQuestion.
1330
type StorySectionQuestion struct {
1331
    CorrectAnswerIndex *int       `json:"correct_answer_index,omitempty"`
1332
    CreatedAt          *time.Time `json:"created_at,omitempty"`
1333
    Explanation        *string    `json:"explanation"`
1334
    Id                 *int64     `json:"id,omitempty"`
1335
    Options            *[]string  `json:"options,omitempty"`
1336
    QuestionText       *string    `json:"question_text,omitempty"`
1337
    SectionId          *int64     `json:"section_id,omitempty"`
1338
}
1339

1340
// StorySectionWithQuestions defines model for StorySectionWithQuestions.
1341
type StorySectionWithQuestions struct {
1342
    Content        *string                 `json:"content,omitempty"`
1343
    GeneratedAt    *time.Time              `json:"generated_at,omitempty"`
1344
    GenerationDate *openapi_types.Date     `json:"generation_date,omitempty"`
1345
    Id             *int64                  `json:"id,omitempty"`
1346
    LanguageLevel  *string                 `json:"language_level,omitempty"`
1347
    Questions      *[]StorySectionQuestion `json:"questions,omitempty"`
1348
    SectionNumber  *int                    `json:"section_number,omitempty"`
1349
    StoryId        *int64                  `json:"story_id,omitempty"`
1350
    WordCount      *int                    `json:"word_count,omitempty"`
1351
}
1352

1353
// StoryWithSections defines model for StoryWithSections.
1354
type StoryWithSections struct {
1355
    AuthorStyle *string `json:"author_style"`
1356

1357
    // AutoGenerationPaused When true, the worker will skip automatic section generation for this story
1358
    AutoGenerationPaused   *bool                                   `json:"auto_generation_paused,omitempty"`
1359
    CharacterNames         *string                                 `json:"character_names"`
1360
    CreatedAt              *time.Time                              `json:"created_at,omitempty"`
1361
    CustomInstructions     *string                                 `json:"custom_instructions"`
1362
    ExtraGenerationsToday  *int                                    `json:"extra_generations_today,omitempty"`
1363
    Genre                  *string                                 `json:"genre"`
1364
    Id                     *int64                                  `json:"id,omitempty"`
1365
    Language               *string                                 `json:"language,omitempty"`
1366
    LastSectionGeneratedAt *time.Time                              `json:"last_section_generated_at"`
1367
    SectionLengthOverride  *StoryWithSectionsSectionLengthOverride `json:"section_length_override,omitempty"`
1368
    Sections               *[]StorySection                         `json:"sections,omitempty"`
1369
    Status                 *StoryWithSectionsStatus                `json:"status,omitempty"`
1370
    Subject                *string                                 `json:"subject"`
1371
    TimePeriod             *string                                 `json:"time_period"`
1372
    Title                  *string                                 `json:"title,omitempty"`
1373
    Tone                   *string                                 `json:"tone"`
1374
    UpdatedAt              *time.Time                              `json:"updated_at,omitempty"`
1375
    UserId                 *int64                                  `json:"user_id,omitempty"`
1376
}
1377

1378
// StoryWithSectionsSectionLengthOverride defines model for StoryWithSections.SectionLengthOverride.
1379
type StoryWithSectionsSectionLengthOverride string
1380

1381
// StoryWithSectionsStatus defines model for StoryWithSections.Status.
1382
type StoryWithSectionsStatus string
1383

1384
// SuccessResponse defines model for SuccessResponse.
1385
type SuccessResponse struct {
1386
    Message *string `json:"message,omitempty"`
1387
    Success bool    `json:"success"`
1388
}
1389

1390
// SystemHealthAnalytics defines model for SystemHealthAnalytics.
1391
type SystemHealthAnalytics struct {
1392
    BackgroundJobs *map[string]interface{} `json:"backgroundJobs,omitempty"`
1393
    Performance    *map[string]interface{} `json:"performance,omitempty"`
1394
}
1395

1396
// TTSRequest defines model for TTSRequest.
1397
type TTSRequest struct {
1398
    // Input The text to convert to speech
1399
    Input string `json:"input"`
1400

1401
    // Model The TTS model to use
1402
    Model *string `json:"model,omitempty"`
1403

1404
    // StreamFormat The format for streaming audio data
1405
    StreamFormat *TTSRequestStreamFormat `json:"stream_format,omitempty"`
1406

1407
    // Voice The voice to use for speech generation
1408
    Voice *string `json:"voice,omitempty"`
1409
}
1410

1411
// TTSRequestStreamFormat The format for streaming audio data
1412
type TTSRequestStreamFormat string
1413

1414
// TTSResponse defines model for TTSResponse.
1415
type TTSResponse struct {
1416
    // Audio Base64 encoded audio chunk (for type=audio)
1417
    Audio *string `json:"audio,omitempty"`
1418

1419
    // Error Error message (for type=error)
1420
    Error *string `json:"error,omitempty"`
1421

1422
    // Type The type of SSE event
1423
    Type *TTSResponseType `json:"type,omitempty"`
1424

1425
    // Usage Usage statistics (for type=usage)
1426
    Usage *struct {
1427
        // InputTokens Number of input tokens processed
1428
        InputTokens *int `json:"input_tokens,omitempty"`
1429

1430
        // OutputTokens Number of output tokens generated
1431
        OutputTokens *int `json:"output_tokens,omitempty"`
1432

1433
        // TotalTokens Total tokens used
1434
        TotalTokens *int `json:"total_tokens,omitempty"`
1435
    } `json:"usage,omitempty"`
1436
}
1437

1438
// TTSResponseType The type of SSE event
1439
type TTSResponseType string
1440

1441
// TestAIRequest defines model for TestAIRequest.
1442
type TestAIRequest struct {
1443
    // ApiKey API key for the provider. If not provided, the server will try to use a saved key.
1444
    ApiKey *string `json:"api_key"`
1445

1446
    // Model AI model code (e.g., "llama3", "gpt-4")
1447
    Model string `json:"model"`
1448

1449
    // Provider AI provider code (e.g., "ollama", "openai")
1450
    Provider string `json:"provider"`
1451
}
1452

1453
// ToggleAutoGenerationRequest defines model for ToggleAutoGenerationRequest.
1454
type ToggleAutoGenerationRequest struct {
1455
    // Paused Whether to pause (true) or resume (false) auto-generation
1456
    Paused bool `json:"paused"`
1457
}
1458

1459
// ToggleAutoGenerationResponse defines model for ToggleAutoGenerationResponse.
1460
type ToggleAutoGenerationResponse struct {
1461
    AutoGenerationPaused *bool   `json:"auto_generation_paused,omitempty"`
1462
    Message              *string `json:"message,omitempty"`
1463
}
1464

1465
// TranslateRequest defines model for TranslateRequest.
1466
type TranslateRequest struct {
1467
    // SourceLanguage Source language code (optional - will be auto-detected if not provided)
1468
    SourceLanguage *string `json:"source_language,omitempty"`
1469

1470
    // TargetLanguage Target language code (e.g., 'en', 'es', 'fr')
1471
    TargetLanguage string `json:"target_language"`
1472

1473
    // Text Text to translate
1474
    Text string `json:"text"`
1475
}
1476

1477
// TranslateResponse defines model for TranslateResponse.
1478
type TranslateResponse struct {
1479
    // Confidence Translation confidence score (if available from provider)
1480
    Confidence *float32 `json:"confidence,omitempty"`
1481

1482
    // SourceLanguage Detected or provided source language code
1483
    SourceLanguage string `json:"source_language"`
1484

1485
    // TargetLanguage Target language code that was requested
1486
    TargetLanguage string `json:"target_language"`
1487

1488
    // TranslatedText The translated text
1489
    TranslatedText string `json:"translated_text"`
1490
}
1491

1492
// UpdateConversationRequest defines model for UpdateConversationRequest.
1493
type UpdateConversationRequest struct {
1494
    // Title New title for the conversation
1495
    Title string `json:"title"`
1496
}
1497

1498
// UpdateSnippetRequest defines model for UpdateSnippetRequest.
1499
type UpdateSnippetRequest struct {
1500
    // Context User-provided context or notes about this snippet
1501
    Context *string `json:"context"`
1502

1503
    // OriginalText The original text/word to save
1504
    OriginalText *string `json:"original_text,omitempty"`
1505

1506
    // SourceLanguage ISO language code of the source text
1507
    SourceLanguage *string `json:"source_language,omitempty"`
1508

1509
    // TargetLanguage ISO language code of the target translation
1510
    TargetLanguage *string `json:"target_language,omitempty"`
1511

1512
    // TranslatedText The translated text
1513
    TranslatedText *string `json:"translated_text,omitempty"`
1514
}
1515

1516
// UsageStatsResponse defines model for UsageStatsResponse.
1517
type UsageStatsResponse struct {
1518
    // CacheStats Cache performance statistics across all services
1519
    CacheStats *struct {
1520
        // CacheHitRate Cache hit rate as a percentage
1521
        CacheHitRate *float32 `json:"cache_hit_rate,omitempty"`
1522

1523
        // TotalCacheHitsCharacters Total characters served from cache
1524
        TotalCacheHitsCharacters *int `json:"total_cache_hits_characters,omitempty"`
1525

1526
        // TotalCacheHitsRequests Total number of cache hit requests
1527
        TotalCacheHitsRequests *int `json:"total_cache_hits_requests,omitempty"`
1528

1529
        // TotalCacheMissesRequests Total number of cache miss requests
1530
        TotalCacheMissesRequests *int `json:"total_cache_misses_requests,omitempty"`
1531
    } `json:"cache_stats,omitempty"`
1532

1533
    // MonthlyTotals Monthly totals organized by month (YYYY-MM) and service
1534
    MonthlyTotals map[string]map[string]struct {
1535
        TotalCharacters *int `json:"total_characters,omitempty"`
1536
        TotalRequests   *int `json:"total_requests,omitempty"`
1537
    } `json:"monthly_totals"`
1538

1539
    // Services List of service names
1540
    Services []string `json:"services"`
1541

1542
    // UsageStats Usage statistics organized by service, month (YYYY-MM), and usage type
1543
    UsageStats map[string]map[string]struct {
1544
        CharactersUsed *int `json:"characters_used,omitempty"`
1545
        Quota          *int `json:"quota,omitempty"`
1546
        RequestsMade   *int `json:"requests_made,omitempty"`
1547
    } `json:"usage_stats"`
1548
}
1549

1550
// User defines model for User.
1551
type User struct {
1552
    // AiEnabled Whether AI features are enabled for this user
1553
    AiEnabled    *bool   `json:"ai_enabled"`
1554
    AiModel      *string `json:"ai_model"`
1555
    AiProvider   *string `json:"ai_provider"`
1556
    CreatedAt    *string `json:"created_at,omitempty"`
1557
    CurrentLevel *string `json:"current_level"`
1558
    Email        *string `json:"email"`
1559

1560
    // HasApiKey Whether the user has a valid API key saved for their current AI provider
1561
    HasApiKey *bool  `json:"has_api_key,omitempty"`
1562
    Id        *int64 `json:"id,omitempty"`
1563

1564
    // IsPaused Whether the user is paused (question generation disabled)
1565
    IsPaused          *bool   `json:"is_paused,omitempty"`
1566
    LastActive        *string `json:"last_active"`
1567
    PreferredLanguage *string `json:"preferred_language"`
1568

1569
    // Roles List of roles assigned to the user
1570
    Roles    *[]Role `json:"roles,omitempty"`
1571
    Timezone *string `json:"timezone"`
1572

1573
    // Username Username (1-100 characters, alphanumeric + underscore + email characters, cannot be empty or whitespace-only)
1574
    Username *string `json:"username,omitempty"`
1575

1576
    // WordOfDayEmailEnabled Whether the user has enabled Word of the Day emails
1577
    WordOfDayEmailEnabled *bool `json:"word_of_day_email_enabled,omitempty"`
1578
}
1579

1580
// UserCreateRequest defines model for UserCreateRequest.
1581
type UserCreateRequest struct {
1582
    // AiEnabled Whether AI features are enabled for this user
1583
    AiEnabled *bool `json:"ai_enabled,omitempty"`
1584

1585
    // CurrentLevel Current proficiency level
1586
    CurrentLevel *string `json:"current_level,omitempty"`
1587

1588
    // Email Email address
1589
    Email *openapi_types.Email `json:"email,omitempty"`
1590

1591
    // Password Password (minimum 8 characters)
1592
    Password string `json:"password"`
1593

1594
    // PreferredLanguage Preferred learning language
1595
    PreferredLanguage *string `json:"preferred_language,omitempty"`
1596

1597
    // Timezone Timezone (e.g., "UTC", "America/New_York")
1598
    Timezone *string `json:"timezone,omitempty"`
1599

1600
    // Username Username (1-100 characters, alphanumeric + underscore + email characters, cannot be empty or whitespace-only)
1601
    Username string `json:"username"`
1602
}
1603

1604
// UserIdRequest defines model for UserIdRequest.
1605
type UserIdRequest struct {
1606
    // UserId ID of the user
1607
    UserId int64 `json:"user_id"`
1608
}
1609

1610
// UserLearningPreferences defines model for UserLearningPreferences.
1611
type UserLearningPreferences struct {
1612
    // DailyGoal User-configurable number of daily questions
1613
    DailyGoal *int `json:"daily_goal,omitempty"`
1614

1615
    // DailyReminderEnabled Whether to receive daily reminder emails
1616
    DailyReminderEnabled bool `json:"daily_reminder_enabled"`
1617

1618
    // FocusOnWeakAreas Whether to focus on weak areas
1619
    FocusOnWeakAreas bool `json:"focus_on_weak_areas"`
1620

1621
    // FreshQuestionRatio Ratio of fresh (never seen) questions to show (0-1)
1622
    FreshQuestionRatio float32 `json:"fresh_question_ratio"`
1623

1624
    // KnownQuestionPenalty Penalty multiplier for questions marked as known (0-1)
1625
    KnownQuestionPenalty float32 `json:"known_question_penalty"`
1626

1627
    // ReviewIntervalDays Days between reviews of known questions
1628
    ReviewIntervalDays int `json:"review_interval_days"`
1629

1630
    // TtsVoice Preferred TTS voice (e.g., it-IT-IsabellaNeural)
1631
    TtsVoice *string `json:"tts_voice,omitempty"`
1632

1633
    // WeakAreaBoost Multiplier for weak area questions
1634
    WeakAreaBoost float32 `json:"weak_area_boost"`
1635
}
1636

1637
// UserPerformanceAnalytics defines model for UserPerformanceAnalytics.
1638
type UserPerformanceAnalytics struct {
1639
    LearningPreferences *map[string]interface{}   `json:"learningPreferences,omitempty"`
1640
    WeakAreas           *[]map[string]interface{} `json:"weakAreas,omitempty"`
1641
}
1642

1643
// UserProfile defines model for UserProfile.
1644
type UserProfile struct {
1645
    // AiEnabled Whether AI features are enabled for this user
1646
    AiEnabled    *bool   `json:"ai_enabled"`
1647
    CreatedAt    *string `json:"created_at,omitempty"`
1648
    CurrentLevel *string `json:"current_level,omitempty"`
1649
    Email        *string `json:"email"`
1650
    Id           *int64  `json:"id,omitempty"`
1651

1652
    // IsPaused Whether the user is paused (question generation disabled)
1653
    IsPaused          *bool   `json:"is_paused,omitempty"`
1654
    LastActive        *string `json:"last_active"`
1655
    PreferredLanguage *string `json:"preferred_language"`
1656
    Timezone          *string `json:"timezone"`
1657
    UpdatedAt         *string `json:"updated_at,omitempty"`
1658

1659
    // Username Username (1-100 characters, alphanumeric + underscore + email characters, cannot be empty or whitespace-only)
1660
    Username *string `json:"username,omitempty"`
1661

1662
    // WordOfDayEmailEnabled Whether the user has enabled Word of the Day emails
1663
    WordOfDayEmailEnabled *bool `json:"word_of_day_email_enabled,omitempty"`
1664
}
1665

1666
// UserProgress defines model for UserProgress.
1667
type UserProgress struct {
1668
    AccuracyRate   *float32 `json:"accuracy_rate,omitempty"`
1669
    CorrectAnswers *int     `json:"correct_answers,omitempty"`
1670

1671
    // CurrentLevel Proficiency level (dynamic). Allowed values depend on the selected language and are sourced from config.yaml (e.g., CEFR A1âC2, JLPT N5âN1, HSK1âHSK6).
1672
    CurrentLevel *Level `json:"current_level,omitempty"`
1673

1674
    // GapAnalysis Analysis of learning gaps and areas needing attention
1675
    GapAnalysis     *map[string]interface{} `json:"gap_analysis,omitempty"`
1676
    GenerationFocus *GenerationFocus        `json:"generation_focus,omitempty"`
1677

1678
    // HighPriorityTopics Topics that have high priority scores for the user
1679
    HighPriorityTopics  *[]string                      `json:"high_priority_topics,omitempty"`
1680
    LearningPreferences *UserLearningPreferences       `json:"learning_preferences,omitempty"`
1681
    PerformanceByTopic  *map[string]PerformanceMetrics `json:"performance_by_topic,omitempty"`
1682

1683
    // PriorityDistribution Distribution of question priorities (high, medium, low counts)
1684
    PriorityDistribution *map[string]int   `json:"priority_distribution,omitempty"`
1685
    PriorityInsights     *PriorityInsights `json:"priority_insights,omitempty"`
1686
    RecentActivity       *[]UserResponse   `json:"recent_activity,omitempty"`
1687

1688
    // SuggestedLevel Proficiency level (dynamic). Allowed values depend on the selected language and are sourced from config.yaml (e.g., CEFR A1âC2, JLPT N5âN1, HSK1âHSK6).
1689
    SuggestedLevel *Level        `json:"suggested_level,omitempty"`
1690
    TotalQuestions *int          `json:"total_questions,omitempty"`
1691
    WeakAreas      *[]string     `json:"weak_areas,omitempty"`
1692
    WorkerStatus   *WorkerStatus `json:"worker_status,omitempty"`
1693
}
1694

1695
// UserQuestionStats defines model for UserQuestionStats.
1696
type UserQuestionStats struct {
1697
    AccuracyByLevel  *map[string]float32 `json:"accuracy_by_level,omitempty"`
1698
    AccuracyByType   *map[string]float32 `json:"accuracy_by_type,omitempty"`
1699
    AnsweredByLevel  *map[string]int     `json:"answered_by_level,omitempty"`
1700
    AnsweredByType   *map[string]int     `json:"answered_by_type,omitempty"`
1701
    AvailableByLevel *map[string]int     `json:"available_by_level,omitempty"`
1702
    AvailableByType  *map[string]int     `json:"available_by_type,omitempty"`
1703
    TotalAnswered    *int                `json:"total_answered,omitempty"`
1704
    UserId           *int64              `json:"user_id,omitempty"`
1705
}
1706

1707
// UserResponse defines model for UserResponse.
1708
type UserResponse struct {
1709
    CreatedAt  *string `json:"created_at,omitempty"`
1710
    IsCorrect  *bool   `json:"is_correct,omitempty"`
1711
    QuestionId *int64  `json:"question_id,omitempty"`
1712
}
1713

1714
// UserSettings defines model for UserSettings.
1715
type UserSettings struct {
1716
    // AiEnabled Whether AI features are enabled for this user
1717
    AiEnabled  *bool   `json:"ai_enabled,omitempty"`
1718
    AiModel    *string `json:"ai_model,omitempty"`
1719
    AiProvider *string `json:"ai_provider,omitempty"`
1720

1721
    // ApiKey API key for AI provider (write-only)
1722
    ApiKey *string `json:"api_key,omitempty"`
1723

1724
    // Language Learning language (dynamic). Allowed values come from config.yaml language_levels keys.
1725
    Language *Language `json:"language,omitempty"`
1726

1727
    // Level Proficiency level (dynamic). Allowed values depend on the selected language and are sourced from config.yaml (e.g., CEFR A1âC2, JLPT N5âN1, HSK1âHSK6).
1728
    Level *Level `json:"level,omitempty"`
1729
    union json.RawMessage
1730
}
1731

1732
// UserSettings0 defines model for .
1733
type UserSettings0 = interface{}
1734

1735
// UserSettings1 defines model for .
1736
type UserSettings1 = interface{}
1737

1738
// UserUpdateRequest defines model for UserUpdateRequest.
1739
type UserUpdateRequest struct {
1740
    // AiEnabled Whether AI features are enabled for this user
1741
    AiEnabled *bool `json:"ai_enabled,omitempty"`
1742

1743
    // AiModel AI model code
1744
    AiModel *string `json:"ai_model,omitempty"`
1745

1746
    // AiProvider AI provider code
1747
    AiProvider *string `json:"ai_provider,omitempty"`
1748

1749
    // ApiKey API key for AI provider (write-only)
1750
    ApiKey *string `json:"api_key,omitempty"`
1751

1752
    // CurrentLevel Current proficiency level
1753
    CurrentLevel *string `json:"current_level,omitempty"`
1754

1755
    // Email Email address
1756
    Email *openapi_types.Email `json:"email,omitempty"`
1757

1758
    // PreferredLanguage Preferred learning language
1759
    PreferredLanguage *string `json:"preferred_language,omitempty"`
1760

1761
    // SelectedRoles Array of role names to assign to the user
1762
    SelectedRoles *[]string `json:"selectedRoles,omitempty"`
1763

1764
    // Timezone Timezone (e.g., "UTC", "America/New_York")
1765
    Timezone *string `json:"timezone,omitempty"`
1766

1767
    // Username Username (1-100 characters, alphanumeric + underscore + email characters, cannot be empty or whitespace-only)
1768
    Username *string `json:"username,omitempty"`
1769
    union    json.RawMessage
1770
}
1771

1772
// UserUpdateRequest0 defines model for .
1773
type UserUpdateRequest0 = interface{}
1774

1775
// UserUpdateRequest1 defines model for .
1776
type UserUpdateRequest1 = interface{}
1777

1778
// UserUsageStats defines model for UserUsageStats.
1779
type UserUsageStats struct {
1780
    ApiKeyId         *int64              `json:"api_key_id,omitempty"`
1781
    CompletionTokens *int                `json:"completion_tokens,omitempty"`
1782
    CreatedAt        *string             `json:"created_at,omitempty"`
1783
    Id               *int64              `json:"id,omitempty"`
1784
    Model            *string             `json:"model,omitempty"`
1785
    PromptTokens     *int                `json:"prompt_tokens,omitempty"`
1786
    Provider         *string             `json:"provider,omitempty"`
1787
    RequestsMade     *int                `json:"requests_made,omitempty"`
1788
    ServiceName      *string             `json:"service_name,omitempty"`
1789
    TotalTokens      *int                `json:"total_tokens,omitempty"`
1790
    UpdatedAt        *string             `json:"updated_at,omitempty"`
1791
    UsageDate        *openapi_types.Date `json:"usage_date,omitempty"`
1792
    UsageHour        *int                `json:"usage_hour,omitempty"`
1793
    UsageType        *string             `json:"usage_type,omitempty"`
1794
    UserId           *int64              `json:"user_id,omitempty"`
1795
}
1796

1797
// UserUsageStatsDaily defines model for UserUsageStatsDaily.
1798
type UserUsageStatsDaily struct {
1799
    Model                 *string             `json:"model,omitempty"`
1800
    Provider              *string             `json:"provider,omitempty"`
1801
    ServiceName           *string             `json:"service_name,omitempty"`
1802
    TotalCompletionTokens *int                `json:"total_completion_tokens,omitempty"`
1803
    TotalPromptTokens     *int                `json:"total_prompt_tokens,omitempty"`
1804
    TotalRequests         *int                `json:"total_requests,omitempty"`
1805
    TotalTokens           *int                `json:"total_tokens,omitempty"`
1806
    UsageDate             *openapi_types.Date `json:"usage_date,omitempty"`
1807
    UsageType             *string             `json:"usage_type,omitempty"`
1808
}
1809

1810
// UserUsageStatsHourly defines model for UserUsageStatsHourly.
1811
type UserUsageStatsHourly struct {
1812
    Model                 *string `json:"model,omitempty"`
1813
    Provider              *string `json:"provider,omitempty"`
1814
    ServiceName           *string `json:"service_name,omitempty"`
1815
    TotalCompletionTokens *int    `json:"total_completion_tokens,omitempty"`
1816
    TotalPromptTokens     *int    `json:"total_prompt_tokens,omitempty"`
1817
    TotalRequests         *int    `json:"total_requests,omitempty"`
1818
    TotalTokens           *int    `json:"total_tokens,omitempty"`
1819
    UsageHour             *int    `json:"usage_hour,omitempty"`
1820
    UsageType             *string `json:"usage_type,omitempty"`
1821
}
1822

1823
// WordOfDayEmailPreferenceRequest defines model for WordOfDayEmailPreferenceRequest.
1824
type WordOfDayEmailPreferenceRequest struct {
1825
    // Enabled Whether to enable Word of the Day emails
1826
    Enabled bool `json:"enabled"`
1827
}
1828

1829
// WordOfTheDayDisplay defines model for WordOfTheDayDisplay.
1830
type WordOfTheDayDisplay struct {
1831
    // Context Additional context for the word (primarily for snippets)
1832
    Context *string `json:"context"`
1833

1834
    // Date Date for the word of the day (YYYY-MM-DD)
1835
    Date openapi_types.Date `json:"date"`
1836

1837
    // Explanation Explanation of the word meaning or usage
1838
    Explanation *string `json:"explanation"`
1839

1840
    // Language Source language of the word
1841
    Language string `json:"language"`
1842

1843
    // Level CEFR difficulty level
1844
    Level *string `json:"level"`
1845

1846
    // Sentence Example sentence using the word in context
1847
    Sentence string `json:"sentence"`
1848

1849
    // SourceId ID of the source (question ID or snippet ID)
1850
    SourceId int64 `json:"source_id"`
1851

1852
    // SourceType Source type of the word (from vocabulary question or user snippet)
1853
    SourceType WordOfTheDayDisplaySourceType `json:"source_type"`
1854

1855
    // TopicCategory Topic category for the word
1856
    TopicCategory *string `json:"topic_category"`
1857

1858
    // Translation English translation of the word
1859
    Translation string `json:"translation"`
1860

1861
    // Word The word or phrase being featured
1862
    Word string `json:"word"`
1863
}
1864

1865
// WordOfTheDayDisplaySourceType Source type of the word (from vocabulary question or user snippet)
1866
type WordOfTheDayDisplaySourceType string
1867

1868
// WorkerHealth defines model for WorkerHealth.
1869
type WorkerHealth struct {
1870
    GlobalPaused    *bool `json:"global_paused,omitempty"`
1871
    HealthyCount    *int  `json:"healthy_count,omitempty"`
1872
    TotalCount      *int  `json:"total_count,omitempty"`
1873
    WorkerInstances *[]struct {
1874
        Healthy       *bool `json:"healthy,omitempty"`
1875
        IsPaused      *bool `json:"is_paused,omitempty"`
1876
        IsRunning     *bool `json:"is_running,omitempty"`
1877
        LastHeartbeat *struct {
1878
            Time  *string `json:"Time,omitempty"`
1879
            Valid *bool   `json:"Valid,omitempty"`
1880
        } `json:"last_heartbeat,omitempty"`
1881
        TotalQuestionsGenerated *int    `json:"total_questions_generated,omitempty"`
1882
        TotalRuns               *int    `json:"total_runs,omitempty"`
1883
        WorkerInstance          *string `json:"worker_instance,omitempty"`
1884
    } `json:"worker_instances,omitempty"`
1885
}
1886

1887
// WorkerStatus defines model for WorkerStatus.
1888
type WorkerStatus struct {
1889
    // ErrorMessage Error message if the worker is in an error state
1890
    ErrorMessage *string `json:"error_message"`
1891

1892
    // LastHeartbeat Timestamp of the last heartbeat from the worker
1893
    LastHeartbeat *string `json:"last_heartbeat,omitempty"`
1894

1895
    // Status Current status of the worker
1896
    Status *WorkerStatusStatus `json:"status,omitempty"`
1897
}
1898

1899
// WorkerStatusStatus Current status of the worker
1900
type WorkerStatusStatus string
1901

1902
// WorkerStatusResponse defines model for WorkerStatusResponse.
1903
type WorkerStatusResponse struct {
1904
    // ErrorMessage Error message if worker has errors
1905
    ErrorMessage string `json:"error_message"`
1906

1907
    // GlobalPaused Whether the worker is globally paused
1908
    GlobalPaused bool `json:"global_paused"`
1909

1910
    // HasErrors Whether the worker has encountered errors
1911
    HasErrors bool `json:"has_errors"`
1912

1913
    // HealthyWorkers Number of healthy worker instances
1914
    HealthyWorkers int `json:"healthy_workers"`
1915

1916
    // LastErrorDetails Detailed error information if any
1917
    LastErrorDetails string `json:"last_error_details"`
1918

1919
    // TotalWorkers Total number of worker instances
1920
    TotalWorkers int `json:"total_workers"`
1921

1922
    // UserPaused Whether the user's question generation is paused
1923
    UserPaused bool `json:"user_paused"`
1924

1925
    // WorkerRunning Whether the worker is currently running
1926
    WorkerRunning bool `json:"worker_running"`
1927
}
1928

1929
// DeleteV1AdminBackendFeedbackParams defines parameters for DeleteV1AdminBackendFeedback.
1930
type DeleteV1AdminBackendFeedbackParams struct {
1931
    // Status Status of feedback reports to delete
1932
    Status DeleteV1AdminBackendFeedbackParamsStatus `form:"status" json:"status"`
1933
}
1934

1935
// DeleteV1AdminBackendFeedbackParamsStatus defines parameters for DeleteV1AdminBackendFeedback.
1936
type DeleteV1AdminBackendFeedbackParamsStatus string
1937

1938
// GetV1AdminBackendFeedbackParams defines parameters for GetV1AdminBackendFeedback.
1939
type GetV1AdminBackendFeedbackParams struct {
1940
    // Page Page number
1941
    Page *int `form:"page,omitempty" json:"page,omitempty"`
1942

1943
    // PageSize Number of items per page
1944
    PageSize *int `form:"page_size,omitempty" json:"page_size,omitempty"`
1945

1946
    // Status Filter by status
1947
    Status *GetV1AdminBackendFeedbackParamsStatus `form:"status,omitempty" json:"status,omitempty"`
1948

1949
    // FeedbackType Filter by feedback type
1950
    FeedbackType *string `form:"feedback_type,omitempty" json:"feedback_type,omitempty"`
1951

1952
    // UserId Filter by user ID
1953
    UserId *int `form:"user_id,omitempty" json:"user_id,omitempty"`
1954
}
1955

1956
// GetV1AdminBackendFeedbackParamsStatus defines parameters for GetV1AdminBackendFeedback.
1957
type GetV1AdminBackendFeedbackParamsStatus string
1958

1959
// GetV1AdminBackendQuestionsParams defines parameters for GetV1AdminBackendQuestions.
1960
type GetV1AdminBackendQuestionsParams struct {
1961
    // Page Page number (1-based)
1962
    Page *int `form:"page,omitempty" json:"page,omitempty"`
1963

1964
    // PageSize Number of questions per page
1965
    PageSize *int `form:"page_size,omitempty" json:"page_size,omitempty"`
1966

1967
    // Search Search term for question content
1968
    Search *string `form:"search,omitempty" json:"search,omitempty"`
1969

1970
    // Type Filter by question type
1971
    Type *QuestionType `form:"type,omitempty" json:"type,omitempty"`
1972

1973
    // Status Filter by question status
1974
    Status *QuestionStatus `form:"status,omitempty" json:"status,omitempty"`
1975

1976
    // Language Filter by language
1977
    Language *Language `form:"language,omitempty" json:"language,omitempty"`
1978

1979
    // Level Filter by level
1980
    Level *Level `form:"level,omitempty" json:"level,omitempty"`
1981

1982
    // UserId Filter by user ID (optional)
1983
    UserId *int64 `form:"user_id,omitempty" json:"user_id,omitempty"`
1984
}
1985

1986
// GetV1AdminBackendQuestionsPaginatedParams defines parameters for GetV1AdminBackendQuestionsPaginated.
1987
type GetV1AdminBackendQuestionsPaginatedParams struct {
1988
    // Page Page number (1-based)
1989
    Page *int `form:"page,omitempty" json:"page,omitempty"`
1990

1991
    // PageSize Number of questions per page
1992
    PageSize *int `form:"page_size,omitempty" json:"page_size,omitempty"`
1993

1994
    // Search Search term for question content
1995
    Search *string `form:"search,omitempty" json:"search,omitempty"`
1996

1997
    // Type Filter by question type
1998
    Type *QuestionType `form:"type,omitempty" json:"type,omitempty"`
1999

2000
    // Status Filter by question status
2001
    Status *QuestionStatus `form:"status,omitempty" json:"status,omitempty"`
2002

2003
    // Language Filter by language
2004
    Language *Language `form:"language,omitempty" json:"language,omitempty"`
2005

2006
    // Level Filter by level
2007
    Level *Level `form:"level,omitempty" json:"level,omitempty"`
2008

2009
    // UserId Filter by user ID (optional)
2010
    UserId *int64 `form:"user_id,omitempty" json:"user_id,omitempty"`
2011
}
2012

2013
// PutV1AdminBackendQuestionsIdJSONBody defines parameters for PutV1AdminBackendQuestionsId.
2014
type PutV1AdminBackendQuestionsIdJSONBody struct {
2015
    // Content Updated question content
2016
    Content map[string]interface{} `json:"content"`
2017

2018
    // CorrectAnswer Index of the correct answer
2019
    CorrectAnswer *int `json:"correct_answer,omitempty"`
2020

2021
    // Explanation Explanation for the correct answer
2022
    Explanation string `json:"explanation"`
2023
}
2024

2025
// PostV1AdminBackendQuestionsIdAiFixJSONBody defines parameters for PostV1AdminBackendQuestionsIdAiFix.
2026
type PostV1AdminBackendQuestionsIdAiFixJSONBody struct {
2027
    AdditionalContext *string `json:"additional_context,omitempty"`
2028
}
2029

2030
// PostV1AdminBackendQuestionsIdAssignUsersJSONBody defines parameters for PostV1AdminBackendQuestionsIdAssignUsers.
2031
type PostV1AdminBackendQuestionsIdAssignUsersJSONBody struct {
2032
    // UserIds Array of user IDs to assign to the question
2033
    UserIds []int64 `json:"user_ids"`
2034
}
2035

2036
// PostV1AdminBackendQuestionsIdUnassignUsersJSONBody defines parameters for PostV1AdminBackendQuestionsIdUnassignUsers.
2037
type PostV1AdminBackendQuestionsIdUnassignUsersJSONBody struct {
2038
    // UserIds Array of user IDs to unassign from the question
2039
    UserIds []int64 `json:"user_ids"`
2040
}
2041

2042
// GetV1AdminBackendReportedQuestionsParams defines parameters for GetV1AdminBackendReportedQuestions.
2043
type GetV1AdminBackendReportedQuestionsParams struct {
2044
    // Page Page number (1-based)
2045
    Page *int `form:"page,omitempty" json:"page,omitempty"`
2046

2047
    // PageSize Number of questions per page
2048
    PageSize *int `form:"page_size,omitempty" json:"page_size,omitempty"`
2049

2050
    // Search Search term for question content
2051
    Search *string `form:"search,omitempty" json:"search,omitempty"`
2052

2053
    // Type Filter by question type
2054
    Type *QuestionType `form:"type,omitempty" json:"type,omitempty"`
2055

2056
    // Language Filter by language
2057
    Language *Language `form:"language,omitempty" json:"language,omitempty"`
2058

2059
    // Level Filter by level
2060
    Level *Level `form:"level,omitempty" json:"level,omitempty"`
2061
}
2062

2063
// GetV1AdminBackendStoriesParams defines parameters for GetV1AdminBackendStories.
2064
type GetV1AdminBackendStoriesParams struct {
2065
    // Page Page number (1-based)
2066
    Page *int `form:"page,omitempty" json:"page,omitempty"`
2067

2068
    // PageSize Number of stories per page
2069
    PageSize *int `form:"page_size,omitempty" json:"page_size,omitempty"`
2070

2071
    // Search Search term for story title
2072
    Search *string `form:"search,omitempty" json:"search,omitempty"`
2073

2074
    // Language Filter by language
2075
    Language *Language `form:"language,omitempty" json:"language,omitempty"`
2076

2077
    // Status Filter by story status
2078
    Status *StoryStatus `form:"status,omitempty" json:"status,omitempty"`
2079

2080
    // UserId Filter by user ID (optional)
2081
    UserId *int64 `form:"user_id,omitempty" json:"user_id,omitempty"`
2082
}
2083

2084
// PostV1AdminBackendUserzJSONBody defines parameters for PostV1AdminBackendUserz.
2085
type PostV1AdminBackendUserzJSONBody struct {
2086
    // AiEnabled Whether AI is enabled for this user
2087
    AiEnabled *bool `json:"ai_enabled,omitempty"`
2088

2089
    // AiModel AI model preference
2090
    AiModel *string `json:"ai_model,omitempty"`
2091

2092
    // AiProvider AI provider preference
2093
    AiProvider *string `json:"ai_provider,omitempty"`
2094

2095
    // Email Email address for the new user
2096
    Email openapi_types.Email `json:"email"`
2097

2098
    // Language Preferred language for the user
2099
    Language *string `json:"language,omitempty"`
2100

2101
    // Level Current level for the user
2102
    Level *string `json:"level,omitempty"`
2103

2104
    // Password Password for the new user
2105
    Password string `json:"password"`
2106

2107
    // Username Username (1-100 characters, alphanumeric + underscore + email characters, cannot be empty or whitespace-only)
2108
    Username string `json:"username"`
2109
}
2110

2111
// GetV1AdminBackendUserzPaginatedParams defines parameters for GetV1AdminBackendUserzPaginated.
2112
type GetV1AdminBackendUserzPaginatedParams struct {
2113
    // Page Page number (1-based)
2114
    Page *int `form:"page,omitempty" json:"page,omitempty"`
2115

2116
    // PageSize Number of users per page
2117
    PageSize *int `form:"page_size,omitempty" json:"page_size,omitempty"`
2118

2119
    // Search Search term for username or email
2120
    Search *string `form:"search,omitempty" json:"search,omitempty"`
2121

2122
    // Language Filter by preferred language
2123
    Language *Language `form:"language,omitempty" json:"language,omitempty"`
2124

2125
    // Level Filter by current level
2126
    Level *Level `form:"level,omitempty" json:"level,omitempty"`
2127

2128
    // AiProvider Filter by AI provider
2129
    AiProvider *string `form:"ai_provider,omitempty" json:"ai_provider,omitempty"`
2130

2131
    // AiModel Filter by AI model
2132
    AiModel *string `form:"ai_model,omitempty" json:"ai_model,omitempty"`
2133

2134
    // AiEnabled Filter by AI enabled status
2135
    AiEnabled *GetV1AdminBackendUserzPaginatedParamsAiEnabled `form:"ai_enabled,omitempty" json:"ai_enabled,omitempty"`
2136

2137
    // Active Filter by active status (active within 7 days)
2138
    Active *GetV1AdminBackendUserzPaginatedParamsActive `form:"active,omitempty" json:"active,omitempty"`
2139
}
2140

2141
// GetV1AdminBackendUserzPaginatedParamsAiEnabled defines parameters for GetV1AdminBackendUserzPaginated.
2142
type GetV1AdminBackendUserzPaginatedParamsAiEnabled string
2143

2144
// GetV1AdminBackendUserzPaginatedParamsActive defines parameters for GetV1AdminBackendUserzPaginated.
2145
type GetV1AdminBackendUserzPaginatedParamsActive string
2146

2147
// PostV1AdminBackendUserzIdRolesJSONBody defines parameters for PostV1AdminBackendUserzIdRoles.
2148
type PostV1AdminBackendUserzIdRolesJSONBody struct {
2149
    // RoleId Role ID to assign
2150
    RoleId int64 `json:"role_id"`
2151
}
2152

2153
// GetV1AdminWorkerNotificationsErrorsParams defines parameters for GetV1AdminWorkerNotificationsErrors.
2154
type GetV1AdminWorkerNotificationsErrorsParams struct {
2155
    // Page Page number (1-based)
2156
    Page *int `form:"page,omitempty" json:"page,omitempty"`
2157

2158
    // PageSize Number of errors per page
2159
    PageSize *int `form:"page_size,omitempty" json:"page_size,omitempty"`
2160

2161
    // ErrorType Filter by error type
2162
    ErrorType *GetV1AdminWorkerNotificationsErrorsParamsErrorType `form:"error_type,omitempty" json:"error_type,omitempty"`
2163

2164
    // NotificationType Filter by notification type
2165
    NotificationType *GetV1AdminWorkerNotificationsErrorsParamsNotificationType `form:"notification_type,omitempty" json:"notification_type,omitempty"`
2166

2167
    // Resolved Filter by resolution status
2168
    Resolved *GetV1AdminWorkerNotificationsErrorsParamsResolved `form:"resolved,omitempty" json:"resolved,omitempty"`
2169
}
2170

2171
// GetV1AdminWorkerNotificationsErrorsParamsErrorType defines parameters for GetV1AdminWorkerNotificationsErrors.
2172
type GetV1AdminWorkerNotificationsErrorsParamsErrorType string
2173

2174
// GetV1AdminWorkerNotificationsErrorsParamsNotificationType defines parameters for GetV1AdminWorkerNotificationsErrors.
2175
type GetV1AdminWorkerNotificationsErrorsParamsNotificationType string
2176

2177
// GetV1AdminWorkerNotificationsErrorsParamsResolved defines parameters for GetV1AdminWorkerNotificationsErrors.
2178
type GetV1AdminWorkerNotificationsErrorsParamsResolved string
2179

2180
// PostV1AdminWorkerNotificationsForceSendJSONBody defines parameters for PostV1AdminWorkerNotificationsForceSend.
2181
type PostV1AdminWorkerNotificationsForceSendJSONBody struct {
2182
    // Username Username of the user to send notification to
2183
    Username string `json:"username"`
2184
}
2185

2186
// GetV1AdminWorkerNotificationsSentParams defines parameters for GetV1AdminWorkerNotificationsSent.
2187
type GetV1AdminWorkerNotificationsSentParams struct {
2188
    // Page Page number (1-based)
2189
    Page *int `form:"page,omitempty" json:"page,omitempty"`
2190

2191
    // PageSize Number of notifications per page
2192
    PageSize *int `form:"page_size,omitempty" json:"page_size,omitempty"`
2193

2194
    // NotificationType Filter by notification type
2195
    NotificationType *GetV1AdminWorkerNotificationsSentParamsNotificationType `form:"notification_type,omitempty" json:"notification_type,omitempty"`
2196

2197
    // Status Filter by status
2198
    Status *GetV1AdminWorkerNotificationsSentParamsStatus `form:"status,omitempty" json:"status,omitempty"`
2199

2200
    // SentAfter Filter notifications sent after this timestamp
2201
    SentAfter *string `form:"sent_after,omitempty" json:"sent_after,omitempty"`
2202

2203
    // SentBefore Filter notifications sent before this timestamp
2204
    SentBefore *string `form:"sent_before,omitempty" json:"sent_before,omitempty"`
2205
}
2206

2207
// GetV1AdminWorkerNotificationsSentParamsNotificationType defines parameters for GetV1AdminWorkerNotificationsSent.
2208
type GetV1AdminWorkerNotificationsSentParamsNotificationType string
2209

2210
// GetV1AdminWorkerNotificationsSentParamsStatus defines parameters for GetV1AdminWorkerNotificationsSent.
2211
type GetV1AdminWorkerNotificationsSentParamsStatus string
2212

2213
// GetV1AiBookmarksParams defines parameters for GetV1AiBookmarks.
2214
type GetV1AiBookmarksParams struct {
2215
    // Q Optional search query to filter bookmarked messages
2216
    Q *string `form:"q,omitempty" json:"q,omitempty"`
2217

2218
    // Limit Maximum number of messages to return
2219
    Limit *int `form:"limit,omitempty" json:"limit,omitempty"`
2220

2221
    // Offset Number of messages to skip
2222
    Offset *int `form:"offset,omitempty" json:"offset,omitempty"`
2223
}
2224

2225
// GetV1AiConversationsParams defines parameters for GetV1AiConversations.
2226
type GetV1AiConversationsParams struct {
2227
    // Limit Maximum number of conversations to return
2228
    Limit *int `form:"limit,omitempty" json:"limit,omitempty"`
2229

2230
    // Offset Number of conversations to skip
2231
    Offset *int `form:"offset,omitempty" json:"offset,omitempty"`
2232
}
2233

2234
// PutV1AiConversationsBookmarkJSONBody defines parameters for PutV1AiConversationsBookmark.
2235
type PutV1AiConversationsBookmarkJSONBody struct {
2236
    // ConversationId ID of the conversation containing the message
2237
    ConversationId openapi_types.UUID `json:"conversation_id"`
2238

2239
    // MessageId ID of the message to bookmark/unbookmark
2240
    MessageId openapi_types.UUID `json:"message_id"`
2241
}
2242

2243
// GetV1AiSearchParams defines parameters for GetV1AiSearch.
2244
type GetV1AiSearchParams struct {
2245
    // Q Search query string
2246
    Q string `form:"q" json:"q"`
2247

2248
    // Limit Maximum number of results to return
2249
    Limit *int `form:"limit,omitempty" json:"limit,omitempty"`
2250

2251
    // Offset Number of results to skip
2252
    Offset *int `form:"offset,omitempty" json:"offset,omitempty"`
2253
}
2254

2255
// GetV1AuthGoogleCallbackParams defines parameters for GetV1AuthGoogleCallback.
2256
type GetV1AuthGoogleCallbackParams struct {
2257
    // Code Authorization code from Google
2258
    Code string `form:"code" json:"code"`
2259

2260
    // State State parameter for CSRF protection
2261
    State *string `form:"state,omitempty" json:"state,omitempty"`
2262
}
2263

2264
// PostV1DailyQuestionsDateAnswerQuestionIdJSONBody defines parameters for PostV1DailyQuestionsDateAnswerQuestionId.
2265
type PostV1DailyQuestionsDateAnswerQuestionIdJSONBody struct {
2266
    // UserAnswerIndex Index of the user's selected answer (0-based)
2267
    UserAnswerIndex int `json:"user_answer_index"`
2268
}
2269

2270
// GetV1QuizAiTokenUsageParams defines parameters for GetV1QuizAiTokenUsage.
2271
type GetV1QuizAiTokenUsageParams struct {
2272
    // StartDate Start date in YYYY-MM-DD format
2273
    StartDate openapi_types.Date `form:"startDate" json:"startDate"`
2274

2275
    // EndDate End date in YYYY-MM-DD format
2276
    EndDate openapi_types.Date `form:"endDate" json:"endDate"`
2277
}
2278

2279
// GetV1QuizAiTokenUsageDailyParams defines parameters for GetV1QuizAiTokenUsageDaily.
2280
type GetV1QuizAiTokenUsageDailyParams struct {
2281
    // StartDate Start date in YYYY-MM-DD format
2282
    StartDate openapi_types.Date `form:"startDate" json:"startDate"`
2283

2284
    // EndDate End date in YYYY-MM-DD format
2285
    EndDate openapi_types.Date `form:"endDate" json:"endDate"`
2286
}
2287

2288
// GetV1QuizAiTokenUsageHourlyParams defines parameters for GetV1QuizAiTokenUsageHourly.
2289
type GetV1QuizAiTokenUsageHourlyParams struct {
2290
    // Date Date in YYYY-MM-DD format
2291
    Date openapi_types.Date `form:"date" json:"date"`
2292
}
2293

2294
// GetV1QuizQuestionParams defines parameters for GetV1QuizQuestion.
2295
type GetV1QuizQuestionParams struct {
2296
    // Language Preferred language for the question
2297
    Language *Language `form:"language,omitempty" json:"language,omitempty"`
2298

2299
    // Level Difficulty level for the question
2300
    Level *Level `form:"level,omitempty" json:"level,omitempty"`
2301

2302
    // Type Specific question type(s) to retrieve (comma-separated list). If multiple types are provided, the first valid type will be used.
2303
    Type *string `form:"type,omitempty" json:"type,omitempty"`
2304

2305
    // ExcludeType Question type(s) to exclude from random selection (comma-separated list). Useful for filtering out specific question types from the general quiz.
2306
    ExcludeType *string `form:"exclude_type,omitempty" json:"exclude_type,omitempty"`
2307
}
2308

2309
// GetV1SettingsLevelsParams defines parameters for GetV1SettingsLevels.
2310
type GetV1SettingsLevelsParams struct {
2311
    // Language Language to get levels for (optional - returns all levels if not specified)
2312
    Language *string `form:"language,omitempty" json:"language,omitempty"`
2313
}
2314

2315
// GetV1SnippetsParams defines parameters for GetV1Snippets.
2316
type GetV1SnippetsParams struct {
2317
    // Q Optional search query to filter snippets by text content
2318
    Q *string `form:"q,omitempty" json:"q,omitempty"`
2319

2320
    // SourceLang Filter by source language
2321
    SourceLang *string `form:"source_lang,omitempty" json:"source_lang,omitempty"`
2322

2323
    // TargetLang Filter by target language
2324
    TargetLang *string `form:"target_lang,omitempty" json:"target_lang,omitempty"`
2325

2326
    // StoryId Filter by story ID
2327
    StoryId *int64 `form:"story_id,omitempty" json:"story_id,omitempty"`
2328

2329
    // Level Filter by difficulty level (CEFR level)
2330
    Level *GetV1SnippetsParamsLevel `form:"level,omitempty" json:"level,omitempty"`
2331

2332
    // Limit Maximum number of snippets to return (default 50, max 100)
2333
    Limit *int `form:"limit,omitempty" json:"limit,omitempty"`
2334

2335
    // Offset Number of snippets to skip for pagination
2336
    Offset *int `form:"offset,omitempty" json:"offset,omitempty"`
2337
}
2338

2339
// GetV1SnippetsParamsLevel defines parameters for GetV1Snippets.
2340
type GetV1SnippetsParamsLevel string
2341

2342
// GetV1SnippetsSearchParams defines parameters for GetV1SnippetsSearch.
2343
type GetV1SnippetsSearchParams struct {
2344
    // Q Search query string
2345
    Q string `form:"q" json:"q"`
2346

2347
    // SourceLang Filter results by source language
2348
    SourceLang *string `form:"source_lang,omitempty" json:"source_lang,omitempty"`
2349

2350
    // Limit Maximum number of results to return
2351
    Limit *int `form:"limit,omitempty" json:"limit,omitempty"`
2352

2353
    // Offset Number of results to skip
2354
    Offset *int `form:"offset,omitempty" json:"offset,omitempty"`
2355
}
2356

2357
// GetV1StoryParams defines parameters for GetV1Story.
2358
type GetV1StoryParams struct {
2359
    // IncludeArchived Include archived stories in the response
2360
    IncludeArchived *bool `form:"include_archived,omitempty" json:"include_archived,omitempty"`
2361
}
2362

2363
// GetV1WordOfDayEmbedParams defines parameters for GetV1WordOfDayEmbed.
2364
type GetV1WordOfDayEmbedParams struct {
2365
    // Date Optional date in YYYY-MM-DD format. Defaults to today's date in the user's timezone when omitted.
2366
    Date *openapi_types.Date `form:"date,omitempty" json:"date,omitempty"`
2367
}
2368

2369
// GetV1WordOfDayHistoryParams defines parameters for GetV1WordOfDayHistory.
2370
type GetV1WordOfDayHistoryParams struct {
2371
    // StartDate Start date in YYYY-MM-DD format
2372
    StartDate openapi_types.Date `form:"start_date" json:"start_date"`
2373

2374
    // EndDate End date in YYYY-MM-DD format
2375
    EndDate openapi_types.Date `form:"end_date" json:"end_date"`
2376
}
2377

2378
// PatchV1AdminBackendFeedbackIdJSONRequestBody defines body for PatchV1AdminBackendFeedbackId for application/json ContentType.
2379
type PatchV1AdminBackendFeedbackIdJSONRequestBody = FeedbackUpdateRequest
2380

2381
// PutV1AdminBackendQuestionsIdJSONRequestBody defines body for PutV1AdminBackendQuestionsId for application/json ContentType.
2382
type PutV1AdminBackendQuestionsIdJSONRequestBody PutV1AdminBackendQuestionsIdJSONBody
2383

2384
// PostV1AdminBackendQuestionsIdAiFixJSONRequestBody defines body for PostV1AdminBackendQuestionsIdAiFix for application/json ContentType.
2385
type PostV1AdminBackendQuestionsIdAiFixJSONRequestBody PostV1AdminBackendQuestionsIdAiFixJSONBody
2386

2387
// PostV1AdminBackendQuestionsIdAssignUsersJSONRequestBody defines body for PostV1AdminBackendQuestionsIdAssignUsers for application/json ContentType.
2388
type PostV1AdminBackendQuestionsIdAssignUsersJSONRequestBody PostV1AdminBackendQuestionsIdAssignUsersJSONBody
2389

2390
// PostV1AdminBackendQuestionsIdUnassignUsersJSONRequestBody defines body for PostV1AdminBackendQuestionsIdUnassignUsers for application/json ContentType.
2391
type PostV1AdminBackendQuestionsIdUnassignUsersJSONRequestBody PostV1AdminBackendQuestionsIdUnassignUsersJSONBody
2392

2393
// PostV1AdminBackendUserzJSONRequestBody defines body for PostV1AdminBackendUserz for application/json ContentType.
2394
type PostV1AdminBackendUserzJSONRequestBody PostV1AdminBackendUserzJSONBody
2395

2396
// PutV1AdminBackendUserzIdJSONRequestBody defines body for PutV1AdminBackendUserzId for application/json ContentType.
2397
type PutV1AdminBackendUserzIdJSONRequestBody = UserUpdateRequest
2398

2399
// PostV1AdminBackendUserzIdResetPasswordJSONRequestBody defines body for PostV1AdminBackendUserzIdResetPassword for application/json ContentType.
2400
type PostV1AdminBackendUserzIdResetPasswordJSONRequestBody = PasswordResetRequest
2401

2402
// PostV1AdminBackendUserzIdRolesJSONRequestBody defines body for PostV1AdminBackendUserzIdRoles for application/json ContentType.
2403
type PostV1AdminBackendUserzIdRolesJSONRequestBody PostV1AdminBackendUserzIdRolesJSONBody
2404

2405
// PostV1AdminWorkerNotificationsForceSendJSONRequestBody defines body for PostV1AdminWorkerNotificationsForceSend for application/json ContentType.
2406
type PostV1AdminWorkerNotificationsForceSendJSONRequestBody PostV1AdminWorkerNotificationsForceSendJSONBody
2407

2408
// PostV1AdminWorkerUsersPauseJSONRequestBody defines body for PostV1AdminWorkerUsersPause for application/json ContentType.
2409
type PostV1AdminWorkerUsersPauseJSONRequestBody = UserIdRequest
2410

2411
// PostV1AdminWorkerUsersResumeJSONRequestBody defines body for PostV1AdminWorkerUsersResume for application/json ContentType.
2412
type PostV1AdminWorkerUsersResumeJSONRequestBody = UserIdRequest
2413

2414
// PostV1AiConversationsJSONRequestBody defines body for PostV1AiConversations for application/json ContentType.
2415
type PostV1AiConversationsJSONRequestBody = CreateConversationRequest
2416

2417
// PutV1AiConversationsBookmarkJSONRequestBody defines body for PutV1AiConversationsBookmark for application/json ContentType.
2418
type PutV1AiConversationsBookmarkJSONRequestBody PutV1AiConversationsBookmarkJSONBody
2419

2420
// PostV1AiConversationsConversationIdMessagesJSONRequestBody defines body for PostV1AiConversationsConversationIdMessages for application/json ContentType.
2421
type PostV1AiConversationsConversationIdMessagesJSONRequestBody = CreateMessageRequest
2422

2423
// PutV1AiConversationsIdJSONRequestBody defines body for PutV1AiConversationsId for application/json ContentType.
2424
type PutV1AiConversationsIdJSONRequestBody = UpdateConversationRequest
2425

2426
// PostV1ApiKeysJSONRequestBody defines body for PostV1ApiKeys for application/json ContentType.
2427
type PostV1ApiKeysJSONRequestBody = CreateAPIKeyRequest
2428

2429
// PostV1AudioSpeechJSONRequestBody defines body for PostV1AudioSpeech for application/json ContentType.
2430
type PostV1AudioSpeechJSONRequestBody = TTSRequest
2431

2432
// PostV1AudioSpeechInitJSONRequestBody defines body for PostV1AudioSpeechInit for application/json ContentType.
2433
type PostV1AudioSpeechInitJSONRequestBody = TTSRequest
2434

2435
// PostV1AuthLoginJSONRequestBody defines body for PostV1AuthLogin for application/json ContentType.
2436
type PostV1AuthLoginJSONRequestBody = LoginRequest
2437

2438
// PostV1AuthSignupJSONRequestBody defines body for PostV1AuthSignup for application/json ContentType.
2439
type PostV1AuthSignupJSONRequestBody = UserCreateRequest
2440

2441
// PostV1DailyQuestionsDateAnswerQuestionIdJSONRequestBody defines body for PostV1DailyQuestionsDateAnswerQuestionId for application/json ContentType.
2442
type PostV1DailyQuestionsDateAnswerQuestionIdJSONRequestBody PostV1DailyQuestionsDateAnswerQuestionIdJSONBody
2443

2444
// PostV1FeedbackJSONRequestBody defines body for PostV1Feedback for application/json ContentType.
2445
type PostV1FeedbackJSONRequestBody = FeedbackSubmissionRequest
2446

2447
// PutV1PreferencesLearningJSONRequestBody defines body for PutV1PreferencesLearning for application/json ContentType.
2448
type PutV1PreferencesLearningJSONRequestBody = UserLearningPreferences
2449

2450
// PostV1QuizAnswerJSONRequestBody defines body for PostV1QuizAnswer for application/json ContentType.
2451
type PostV1QuizAnswerJSONRequestBody = AnswerRequest
2452

2453
// PostV1QuizChatStreamJSONRequestBody defines body for PostV1QuizChatStream for application/json ContentType.
2454
type PostV1QuizChatStreamJSONRequestBody = QuizChatRequest
2455

2456
// PostV1QuizQuestionIdMarkKnownJSONRequestBody defines body for PostV1QuizQuestionIdMarkKnown for application/json ContentType.
2457
type PostV1QuizQuestionIdMarkKnownJSONRequestBody = MarkQuestionKnownRequest
2458

2459
// PostV1QuizQuestionIdReportJSONRequestBody defines body for PostV1QuizQuestionIdReport for application/json ContentType.
2460
type PostV1QuizQuestionIdReportJSONRequestBody = ReportQuestionRequest
2461

2462
// PutV1SettingsJSONRequestBody defines body for PutV1Settings for application/json ContentType.
2463
type PutV1SettingsJSONRequestBody = UserSettings
2464

2465
// PostV1SettingsTestAiJSONRequestBody defines body for PostV1SettingsTestAi for application/json ContentType.
2466
type PostV1SettingsTestAiJSONRequestBody = TestAIRequest
2467

2468
// PutV1SettingsWordOfDayEmailJSONRequestBody defines body for PutV1SettingsWordOfDayEmail for application/json ContentType.
2469
type PutV1SettingsWordOfDayEmailJSONRequestBody = WordOfDayEmailPreferenceRequest
2470

2471
// PostV1SnippetsJSONRequestBody defines body for PostV1Snippets for application/json ContentType.
2472
type PostV1SnippetsJSONRequestBody = CreateSnippetRequest
2473

2474
// PutV1SnippetsIdJSONRequestBody defines body for PutV1SnippetsId for application/json ContentType.
2475
type PutV1SnippetsIdJSONRequestBody = UpdateSnippetRequest
2476

2477
// PostV1StoryJSONRequestBody defines body for PostV1Story for application/json ContentType.
2478
type PostV1StoryJSONRequestBody = CreateStoryRequest
2479

2480
// PostV1StoryIdGenerateJSONRequestBody defines body for PostV1StoryIdGenerate for application/json ContentType.
2481
type PostV1StoryIdGenerateJSONRequestBody = EmptyRequest
2482

2483
// PostV1StoryIdToggleAutoGenerationJSONRequestBody defines body for PostV1StoryIdToggleAutoGeneration for application/json ContentType.
2484
type PostV1StoryIdToggleAutoGenerationJSONRequestBody = ToggleAutoGenerationRequest
2485

2486
// PostV1TranslateJSONRequestBody defines body for PostV1Translate for application/json ContentType.
2487
type PostV1TranslateJSONRequestBody = TranslateRequest
2488

2489
// PutV1UserzProfileJSONRequestBody defines body for PutV1UserzProfile for application/json ContentType.
2490
type PutV1UserzProfileJSONRequestBody = UserUpdateRequest
2491

2492
// AsServiceVersion returns the union data inside the AggregatedVersion_Worker as a ServiceVersion
2493
func (t AggregatedVersion_Worker) AsServiceVersion() (ServiceVersion, error) {
2494
    var body ServiceVersion
2495
    err := json.Unmarshal(t.union, &body)
2496
    return body, err
2497
}
2498

2499
// FromServiceVersion overwrites any union data inside the AggregatedVersion_Worker as the provided ServiceVersion
2500
func (t *AggregatedVersion_Worker) FromServiceVersion(v ServiceVersion) error {
2501
    b, err := json.Marshal(v)
2502
    t.union = b
2503
    return err
2504
}
2505

2506
// MergeServiceVersion performs a merge with any union data inside the AggregatedVersion_Worker, using the provided ServiceVersion
2507
func (t *AggregatedVersion_Worker) MergeServiceVersion(v ServiceVersion) error {
2508
    b, err := json.Marshal(v)
2509
    if err != nil {
2510
        return err
2511
    }
2512

2513
    merged, err := runtime.JSONMerge(t.union, b)
2514
    t.union = merged
2515
    return err
2516
}
2517

2518
// AsAggregatedVersionWorker1 returns the union data inside the AggregatedVersion_Worker as a AggregatedVersionWorker1
2519
func (t AggregatedVersion_Worker) AsAggregatedVersionWorker1() (AggregatedVersionWorker1, error) {
2520
    var body AggregatedVersionWorker1
2521
    err := json.Unmarshal(t.union, &body)
2522
    return body, err
2523
}
2524

2525
// FromAggregatedVersionWorker1 overwrites any union data inside the AggregatedVersion_Worker as the provided AggregatedVersionWorker1
2526
func (t *AggregatedVersion_Worker) FromAggregatedVersionWorker1(v AggregatedVersionWorker1) error {
2527
    b, err := json.Marshal(v)
2528
    t.union = b
2529
    return err
2530
}
2531

2532
// MergeAggregatedVersionWorker1 performs a merge with any union data inside the AggregatedVersion_Worker, using the provided AggregatedVersionWorker1
2533
func (t *AggregatedVersion_Worker) MergeAggregatedVersionWorker1(v AggregatedVersionWorker1) error {
2534
    b, err := json.Marshal(v)
2535
    if err != nil {
2536
        return err
2537
    }
2538

2539
    merged, err := runtime.JSONMerge(t.union, b)
2540
    t.union = merged
2541
    return err
2542
}
2543

2544
func (t AggregatedVersion_Worker) MarshalJSON() ([]byte, error) {
2545
    b, err := t.union.MarshalJSON()
2546
    return b, err
2547
}
2548

2549
func (t *AggregatedVersion_Worker) UnmarshalJSON(b []byte) error {
2550
    err := t.union.UnmarshalJSON(b)
2551
    return err
2552
}
2553

2554
// AsUserSettings0 returns the union data inside the UserSettings as a UserSettings0
2555
func (t UserSettings) AsUserSettings0() (UserSettings0, error) {
2556
    var body UserSettings0
2557
    err := json.Unmarshal(t.union, &body)
2558
    return body, err
2559
}
2560

2561
// FromUserSettings0 overwrites any union data inside the UserSettings as the provided UserSettings0
2562
func (t *UserSettings) FromUserSettings0(v UserSettings0) error {
2563
    b, err := json.Marshal(v)
2564
    t.union = b
2565
    return err
2566
}
2567

2568
// MergeUserSettings0 performs a merge with any union data inside the UserSettings, using the provided UserSettings0
2569
func (t *UserSettings) MergeUserSettings0(v UserSettings0) error {
2570
    b, err := json.Marshal(v)
2571
    if err != nil {
2572
        return err
2573
    }
2574

2575
    merged, err := runtime.JSONMerge(t.union, b)
2576
    t.union = merged
2577
    return err
2578
}
2579

2580
// AsUserSettings1 returns the union data inside the UserSettings as a UserSettings1
2581
func (t UserSettings) AsUserSettings1() (UserSettings1, error) {
2582
    var body UserSettings1
2583
    err := json.Unmarshal(t.union, &body)
2584
    return body, err
2585
}
2586

2587
// FromUserSettings1 overwrites any union data inside the UserSettings as the provided UserSettings1
2588
func (t *UserSettings) FromUserSettings1(v UserSettings1) error {
2589
    b, err := json.Marshal(v)
2590
    t.union = b
2591
    return err
2592
}
2593

2594
// MergeUserSettings1 performs a merge with any union data inside the UserSettings, using the provided UserSettings1
2595
func (t *UserSettings) MergeUserSettings1(v UserSettings1) error {
2596
    b, err := json.Marshal(v)
2597
    if err != nil {
2598
        return err
2599
    }
2600

2601
    merged, err := runtime.JSONMerge(t.union, b)
2602
    t.union = merged
2603
    return err
2604
}
2605

2606
func (t UserSettings) MarshalJSON() ([]byte, error) {
2607
    b, err := t.union.MarshalJSON()
2608
    if err != nil {
2609
        return nil, err
2610
    }
2611
    object := make(map[string]json.RawMessage)
2612
    if t.union != nil {
2613
        err = json.Unmarshal(b, &object)
2614
        if err != nil {
2615
            return nil, err
2616
        }
2617
    }
2618

2619
    if t.AiEnabled != nil {
2620
        object["ai_enabled"], err = json.Marshal(t.AiEnabled)
2621
        if err != nil {
2622
            return nil, fmt.Errorf("error marshaling 'ai_enabled': %w", err)
2623
        }
2624
    }
2625

2626
    if t.AiModel != nil {
2627
        object["ai_model"], err = json.Marshal(t.AiModel)
2628
        if err != nil {
2629
            return nil, fmt.Errorf("error marshaling 'ai_model': %w", err)
2630
        }
2631
    }
2632

2633
    if t.AiProvider != nil {
2634
        object["ai_provider"], err = json.Marshal(t.AiProvider)
2635
        if err != nil {
2636
            return nil, fmt.Errorf("error marshaling 'ai_provider': %w", err)
2637
        }
2638
    }
2639

2640
    if t.ApiKey != nil {
2641
        object["api_key"], err = json.Marshal(t.ApiKey)
2642
        if err != nil {
2643
            return nil, fmt.Errorf("error marshaling 'api_key': %w", err)
2644
        }
2645
    }
2646

2647
    if t.Language != nil {
2648
        object["language"], err = json.Marshal(t.Language)
2649
        if err != nil {
2650
            return nil, fmt.Errorf("error marshaling 'language': %w", err)
2651
        }
2652
    }
2653

2654
    if t.Level != nil {
2655
        object["level"], err = json.Marshal(t.Level)
2656
        if err != nil {
2657
            return nil, fmt.Errorf("error marshaling 'level': %w", err)
2658
        }
2659
    }
2660
    b, err = json.Marshal(object)
2661
    return b, err
2662
}
2663

2664
func (t *UserSettings) UnmarshalJSON(b []byte) error {
2665
    err := t.union.UnmarshalJSON(b)
2666
    if err != nil {
2667
        return err
2668
    }
2669
    object := make(map[string]json.RawMessage)
2670
    err = json.Unmarshal(b, &object)
2671
    if err != nil {
2672
        return err
2673
    }
2674

2675
    if raw, found := object["ai_enabled"]; found {
2676
        err = json.Unmarshal(raw, &t.AiEnabled)
2677
        if err != nil {
2678
            return fmt.Errorf("error reading 'ai_enabled': %w", err)
2679
        }
2680
    }
2681

2682
    if raw, found := object["ai_model"]; found {
2683
        err = json.Unmarshal(raw, &t.AiModel)
2684
        if err != nil {
2685
            return fmt.Errorf("error reading 'ai_model': %w", err)
2686
        }
2687
    }
2688

2689
    if raw, found := object["ai_provider"]; found {
2690
        err = json.Unmarshal(raw, &t.AiProvider)
2691
        if err != nil {
2692
            return fmt.Errorf("error reading 'ai_provider': %w", err)
2693
        }
2694
    }
2695

2696
    if raw, found := object["api_key"]; found {
2697
        err = json.Unmarshal(raw, &t.ApiKey)
2698
        if err != nil {
2699
            return fmt.Errorf("error reading 'api_key': %w", err)
2700
        }
2701
    }
2702

2703
    if raw, found := object["language"]; found {
2704
        err = json.Unmarshal(raw, &t.Language)
2705
        if err != nil {
2706
            return fmt.Errorf("error reading 'language': %w", err)
2707
        }
2708
    }
2709

2710
    if raw, found := object["level"]; found {
2711
        err = json.Unmarshal(raw, &t.Level)
2712
        if err != nil {
2713
            return fmt.Errorf("error reading 'level': %w", err)
2714
        }
2715
    }
2716

2717
    return err
2718
}
2719

2720
// AsUserUpdateRequest0 returns the union data inside the UserUpdateRequest as a UserUpdateRequest0
2721
func (t UserUpdateRequest) AsUserUpdateRequest0() (UserUpdateRequest0, error) {
2722
    var body UserUpdateRequest0
2723
    err := json.Unmarshal(t.union, &body)
2724
    return body, err
2725
}
2726

2727
// FromUserUpdateRequest0 overwrites any union data inside the UserUpdateRequest as the provided UserUpdateRequest0
2728
func (t *UserUpdateRequest) FromUserUpdateRequest0(v UserUpdateRequest0) error {
2729
    b, err := json.Marshal(v)
2730
    t.union = b
2731
    return err
2732
}
2733

2734
// MergeUserUpdateRequest0 performs a merge with any union data inside the UserUpdateRequest, using the provided UserUpdateRequest0
2735
func (t *UserUpdateRequest) MergeUserUpdateRequest0(v UserUpdateRequest0) error {
2736
    b, err := json.Marshal(v)
2737
    if err != nil {
2738
        return err
2739
    }
2740

2741
    merged, err := runtime.JSONMerge(t.union, b)
2742
    t.union = merged
2743
    return err
2744
}
2745

2746
// AsUserUpdateRequest1 returns the union data inside the UserUpdateRequest as a UserUpdateRequest1
2747
func (t UserUpdateRequest) AsUserUpdateRequest1() (UserUpdateRequest1, error) {
2748
    var body UserUpdateRequest1
2749
    err := json.Unmarshal(t.union, &body)
2750
    return body, err
2751
}
2752

2753
// FromUserUpdateRequest1 overwrites any union data inside the UserUpdateRequest as the provided UserUpdateRequest1
2754
func (t *UserUpdateRequest) FromUserUpdateRequest1(v UserUpdateRequest1) error {
2755
    b, err := json.Marshal(v)
2756
    t.union = b
2757
    return err
2758
}
2759

2760
// MergeUserUpdateRequest1 performs a merge with any union data inside the UserUpdateRequest, using the provided UserUpdateRequest1
2761
func (t *UserUpdateRequest) MergeUserUpdateRequest1(v UserUpdateRequest1) error {
2762
    b, err := json.Marshal(v)
2763
    if err != nil {
2764
        return err
2765
    }
2766

2767
    merged, err := runtime.JSONMerge(t.union, b)
2768
    t.union = merged
2769
    return err
2770
}
2771

2772
func (t UserUpdateRequest) MarshalJSON() ([]byte, error) {
2773
    b, err := t.union.MarshalJSON()
2774
    if err != nil {
2775
        return nil, err
2776
    }
2777
    object := make(map[string]json.RawMessage)
2778
    if t.union != nil {
2779
        err = json.Unmarshal(b, &object)
2780
        if err != nil {
2781
            return nil, err
2782
        }
2783
    }
2784

2785
    if t.AiEnabled != nil {
2786
        object["ai_enabled"], err = json.Marshal(t.AiEnabled)
2787
        if err != nil {
2788
            return nil, fmt.Errorf("error marshaling 'ai_enabled': %w", err)
2789
        }
2790
    }
2791

2792
    if t.AiModel != nil {
2793
        object["ai_model"], err = json.Marshal(t.AiModel)
2794
        if err != nil {
2795
            return nil, fmt.Errorf("error marshaling 'ai_model': %w", err)
2796
        }
2797
    }
2798

2799
    if t.AiProvider != nil {
2800
        object["ai_provider"], err = json.Marshal(t.AiProvider)
2801
        if err != nil {
2802
            return nil, fmt.Errorf("error marshaling 'ai_provider': %w", err)
2803
        }
2804
    }
2805

2806
    if t.ApiKey != nil {
2807
        object["api_key"], err = json.Marshal(t.ApiKey)
2808
        if err != nil {
2809
            return nil, fmt.Errorf("error marshaling 'api_key': %w", err)
2810
        }
2811
    }
2812

2813
    if t.CurrentLevel != nil {
2814
        object["current_level"], err = json.Marshal(t.CurrentLevel)
2815
        if err != nil {
2816
            return nil, fmt.Errorf("error marshaling 'current_level': %w", err)
2817
        }
2818
    }
2819

2820
    if t.Email != nil {
2821
        object["email"], err = json.Marshal(t.Email)
2822
        if err != nil {
2823
            return nil, fmt.Errorf("error marshaling 'email': %w", err)
2824
        }
2825
    }
2826

2827
    if t.PreferredLanguage != nil {
2828
        object["preferred_language"], err = json.Marshal(t.PreferredLanguage)
2829
        if err != nil {
2830
            return nil, fmt.Errorf("error marshaling 'preferred_language': %w", err)
2831
        }
2832
    }
2833

2834
    if t.SelectedRoles != nil {
2835
        object["selectedRoles"], err = json.Marshal(t.SelectedRoles)
2836
        if err != nil {
2837
            return nil, fmt.Errorf("error marshaling 'selectedRoles': %w", err)
2838
        }
2839
    }
2840

2841
    if t.Timezone != nil {
2842
        object["timezone"], err = json.Marshal(t.Timezone)
2843
        if err != nil {
2844
            return nil, fmt.Errorf("error marshaling 'timezone': %w", err)
2845
        }
2846
    }
2847

2848
    if t.Username != nil {
2849
        object["username"], err = json.Marshal(t.Username)
2850
        if err != nil {
2851
            return nil, fmt.Errorf("error marshaling 'username': %w", err)
2852
        }
2853
    }
2854
    b, err = json.Marshal(object)
2855
    return b, err
2856
}
2857

2858
func (t *UserUpdateRequest) UnmarshalJSON(b []byte) error {
2859
    err := t.union.UnmarshalJSON(b)
2860
    if err != nil {
2861
        return err
2862
    }
2863
    object := make(map[string]json.RawMessage)
2864
    err = json.Unmarshal(b, &object)
2865
    if err != nil {
2866
        return err
2867
    }
2868

2869
    if raw, found := object["ai_enabled"]; found {
2870
        err = json.Unmarshal(raw, &t.AiEnabled)
2871
        if err != nil {
2872
            return fmt.Errorf("error reading 'ai_enabled': %w", err)
2873
        }
2874
    }
2875

2876
    if raw, found := object["ai_model"]; found {
2877
        err = json.Unmarshal(raw, &t.AiModel)
2878
        if err != nil {
2879
            return fmt.Errorf("error reading 'ai_model': %w", err)
2880
        }
2881
    }
2882

2883
    if raw, found := object["ai_provider"]; found {
2884
        err = json.Unmarshal(raw, &t.AiProvider)
2885
        if err != nil {
2886
            return fmt.Errorf("error reading 'ai_provider': %w", err)
2887
        }
2888
    }
2889

2890
    if raw, found := object["api_key"]; found {
2891
        err = json.Unmarshal(raw, &t.ApiKey)
2892
        if err != nil {
2893
            return fmt.Errorf("error reading 'api_key': %w", err)
2894
        }
2895
    }
2896

2897
    if raw, found := object["current_level"]; found {
2898
        err = json.Unmarshal(raw, &t.CurrentLevel)
2899
        if err != nil {
2900
            return fmt.Errorf("error reading 'current_level': %w", err)
2901
        }
2902
    }
2903

2904
    if raw, found := object["email"]; found {
2905
        err = json.Unmarshal(raw, &t.Email)
2906
        if err != nil {
2907
            return fmt.Errorf("error reading 'email': %w", err)
2908
        }
2909
    }
2910

2911
    if raw, found := object["preferred_language"]; found {
2912
        err = json.Unmarshal(raw, &t.PreferredLanguage)
2913
        if err != nil {
2914
            return fmt.Errorf("error reading 'preferred_language': %w", err)
2915
        }
2916
    }
2917

2918
    if raw, found := object["selectedRoles"]; found {
2919
        err = json.Unmarshal(raw, &t.SelectedRoles)
2920
        if err != nil {
2921
            return fmt.Errorf("error reading 'selectedRoles': %w", err)
2922
        }
2923
    }
2924

2925
    if raw, found := object["timezone"]; found {
2926
        err = json.Unmarshal(raw, &t.Timezone)
2927
        if err != nil {
2928
            return fmt.Errorf("error reading 'timezone': %w", err)
2929
        }
2930
    }
2931

2932
    if raw, found := object["username"]; found {
2933
        err = json.Unmarshal(raw, &t.Username)
2934
        if err != nil {
2935
            return fmt.Errorf("error reading 'username': %w", err)
2936
        }
2937
    }
2938

2939
    return err
2940
}
2941


			
quizapp internal config
79.8%
Statements
162/203
config.go
79.8%
162/203
quizapp internal config config.go
79.8%
Statements
162/203
1
// Package config handles application configuration loading from environment variables.
2
package config
3

4
import (
5
    "fmt"
6
    "os"
7
    "reflect"
8
    "sort"
9
    "strconv"
10
    "strings"
11
    "time"
12

13
    contextutils "quizapp/internal/utils"
14

15
    "gopkg.in/yaml.v3"
16
)
17

18
// ProviderConfig defines the structure for a single provider
19
type ProviderConfig struct {
20
    Name              string    `json:"name" yaml:"name"`
21
    Code              string    `json:"code" yaml:"code"`
22
    URL               string    `json:"url,omitempty" yaml:"url,omitempty"`
23
    SupportsGrammar   bool      `json:"supports_grammar" yaml:"supports_grammar"`
24
    UsageSupported    bool      `json:"usage_supported" yaml:"usage_supported"`
25
    QuestionBatchSize int       `json:"question_batch_size,omitempty" yaml:"question_batch_size,omitempty"`
26
    Models            []AIModel `json:"models" yaml:"models"`
27
}
28

29
// AIModel represents an AI model configuration
30
type AIModel struct {
31
    Name      string `json:"name" yaml:"name"`
32
    Code      string `json:"code" yaml:"code"`
33
    MaxTokens int    `json:"max_tokens,omitempty" yaml:"max_tokens,omitempty"`
34
}
35

36
// QuestionVarietyConfig defines the variety configuration for question generation
37
type QuestionVarietyConfig struct {
38
    TopicCategories     []string            `json:"topic_categories" yaml:"topic_categories"`
39
    GrammarFocusByLevel map[string][]string `json:"grammar_focus_by_level" yaml:"grammar_focus_by_level"`
40
    GrammarFocus        []string            `json:"grammar_focus" yaml:"grammar_focus"`
41
    VocabularyDomains   []string            `json:"vocabulary_domains" yaml:"vocabulary_domains"`
42
    Scenarios           []string            `json:"scenarios" yaml:"scenarios"`
43
    StyleModifiers      []string            `json:"style_modifiers" yaml:"style_modifiers"`
44
    DifficultyModifiers []string            `json:"difficulty_modifiers" yaml:"difficulty_modifiers"`
45
    TimeContexts        []string            `json:"time_contexts" yaml:"time_contexts"`
46
}
47

48
// LanguageLevelConfig represents the levels and descriptions for a specific language
49
type LanguageLevelConfig struct {
50
    Code         string            `json:"code" yaml:"code"`
51
    TtsLocale    string            `json:"tts_locale" yaml:"tts_locale"`
52
    TtsVoice     string            `json:"tts_voice" yaml:"tts_voice"`
53
    Levels       []string          `json:"levels" yaml:"levels"`
54
    Descriptions map[string]string `json:"descriptions" yaml:"descriptions"`
55
}
56

57
// LanguageInfo represents a language with its code and human-readable name
58
type LanguageInfo struct {
59
    Code      string  `json:"code"`
60
    Name      string  `json:"name"`
61
    TtsLocale *string `json:"tts_locale,omitempty"`
62
    TtsVoice  *string `json:"tts_voice,omitempty"`
63
}
64

65
// AuthConfig represents authentication-related configuration
66
type AuthConfig struct {
67
    SignupsDisabled bool     `json:"signups_disabled" yaml:"signups_disabled"`
68
    AllowedDomains  []string `json:"allowed_domains,omitempty" yaml:"allowed_domains,omitempty"`
69
    AllowedEmails   []string `json:"allowed_emails,omitempty" yaml:"allowed_emails,omitempty"`
70
}
71

72
// SystemConfig represents system-wide configuration
73
type SystemConfig struct {
74
    Auth AuthConfig `json:"auth" yaml:"auth"`
75
}
76

77
// Config holds all configuration for the application
78
type Config struct {
79
    // Server configuration
80
    Server ServerConfig `json:"server" yaml:"server"`
81

82
    // Database configuration
83
    Database DatabaseConfig `json:"database" yaml:"database"`
84

85
    // AI Providers and Language Levels
86
    Providers      []ProviderConfig               `json:"providers" yaml:"providers"`
87
    LanguageLevels map[string]LanguageLevelConfig `json:"language_levels" yaml:"language_levels"`
88
    Variety        *QuestionVarietyConfig         `json:"variety,omitempty" yaml:"variety,omitempty"`
89
    System         *SystemConfig                  `json:"system,omitempty" yaml:"system,omitempty"`
90

91
    // OAuth Configuration
92
    GoogleOAuthClientID     string `json:"google_oauth_client_id" yaml:"google_oauth_client_id"`
93
    GoogleOAuthClientSecret string `json:"google_oauth_client_secret" yaml:"google_oauth_client_secret"`
94
    GoogleOAuthRedirectURL  string `json:"google_oauth_redirect_url" yaml:"google_oauth_redirect_url"`
95

96
    // OpenTelemetry Configuration
97
    OpenTelemetry OpenTelemetryConfig `json:"open_telemetry" yaml:"open_telemetry"`
98

99
    // Email Configuration
100
    Email EmailConfig `json:"email" yaml:"email"`
101

102
    // Story Configuration
103
    Story StoryConfig `json:"story" yaml:"story"`
104

105
    // Translation Configuration
106
    Translation TranslationConfig `json:"translation" yaml:"translation"`
107

108
    // Linear Configuration
109
    Linear LinearConfig `json:"linear" yaml:"linear"`
110

111
    // Internal fields
112
    IsTest bool `json:"is_test" yaml:"is_test"`
113
}
114

115
// ServerConfig represents server configuration
116
type ServerConfig struct {
117
    Port                    string   `json:"port" yaml:"port"`
118
    WorkerPort              string   `json:"worker_port" yaml:"worker_port"`
119
    AdminUsername           string   `json:"admin_username" yaml:"admin_username"`
120
    AdminPassword           string   `json:"admin_password" yaml:"admin_password"`
121
    SessionSecret           string   `json:"session_secret" yaml:"session_secret"`
122
    Debug                   bool     `json:"debug" yaml:"debug"`
123
    LogLevel                string   `json:"log_level" yaml:"log_level"`
124
    WorkerBaseURL           string   `json:"worker_base_url" yaml:"worker_base_url"`
125
    WorkerInternalURL       string   `json:"worker_internal_url" yaml:"worker_internal_url"`
126
    BackendBaseURL          string   `json:"backend_base_url" yaml:"backend_base_url"`
127
    AppBaseURL              string   `json:"app_base_url" yaml:"app_base_url"`
128
    MaxAIConcurrent         int      `json:"max_ai_concurrent" yaml:"max_ai_concurrent"`
129
    MaxAIPerUser            int      `json:"max_ai_per_user" yaml:"max_ai_per_user"`
130
    CORSOrigins             []string `json:"cors_origins" yaml:"cors_origins"`
131
    QuestionRefillThreshold int      `json:"question_refill_threshold" yaml:"question_refill_threshold"`
132
    // DailyFreshQuestionRatio controls the minimum fraction of fresh (never-seen)
133
    // questions to aim for when refilling question pools (0.0 - 1.0). Example: 0.35
134
    // means at least 35% fresh questions when refilling.
135
    DailyFreshQuestionRatio float64 `json:"daily_fresh_question_ratio" yaml:"daily_fresh_question_ratio"`
136
    MaxHistory              int     `json:"max_history" yaml:"max_history"`
137
    MaxActivityLogs         int     `json:"max_activity_logs" yaml:"max_activity_logs"`
138
    DailyRepeatAvoidDays    int     `json:"daily_repeat_avoid_days" yaml:"daily_repeat_avoid_days"`
139
    // DailyHorizonDays controls how many days ahead the worker will assign
140
    // daily questions (e.g. 0 = today only, 1 = today+1, ...). If unset or
141
    // <= 0 the worker will fall back to the DAILY_HORIZON_DAYS environment
142
    // variable (default 1).
143
    DailyHorizonDays int `json:"daily_horizon_days" yaml:"daily_horizon_days"`
144
}
145

146
// GetLanguages returns a slice of all supported languages (derived from language_levels keys)
147
4x
func (c *Config) GetLanguages() []string {
148
4x
    if c.LanguageLevels == nil {
149
        return []string{}
150
    }
151

152
4x
    languages := make([]string, 0, len(c.LanguageLevels))
153
4x
    for lang := range c.LanguageLevels {
154
24x
        languages = append(languages, lang)
155
24x
    }
156

157
4x
    sort.Strings(languages)
158
4x
    return languages
159
}
160

161
// GetLanguageInfoList returns a slice of language info objects with code and name
162
func (c *Config) GetLanguageInfoList() []LanguageInfo {
163
    if c.LanguageLevels == nil {
164
        return []LanguageInfo{}
165
    }
166

167
    languageInfos := make([]LanguageInfo, 0, len(c.LanguageLevels))
168
    for langName, langConfig := range c.LanguageLevels {
169
        var ttsLocale, ttsVoice *string
170
        if langConfig.TtsLocale != "" {
171
            ttsLocale = &langConfig.TtsLocale
172
        }
173
        if langConfig.TtsVoice != "" {
174
            ttsVoice = &langConfig.TtsVoice
175
        }
176

177
        languageInfos = append(languageInfos, LanguageInfo{
178
            Code:      langConfig.Code,
179
            Name:      langName,
180
            TtsLocale: ttsLocale,
181
            TtsVoice:  ttsVoice,
182
        })
183
    }
184

185
    // Sort by name for consistent ordering
186
    sort.Slice(languageInfos, func(i, j int) bool {
187
        return languageInfos[i].Name < languageInfos[j].Name
188
    })
189

190
    return languageInfos
191
}
192

193
// GetLevelsForLanguage returns the levels for a specific language
194
11x
func (c *Config) GetLevelsForLanguage(language string) []string {
195
11x
    if c.LanguageLevels == nil {
196
        return []string{}
197
    }
198

199
    // First try to look up by language name directly
200
11x
    if langConfig, exists := c.LanguageLevels[language]; exists {
201
5x
        return langConfig.Levels
202
5x
    }
203

204
    // If not found by name, try to find by language code
205
6x
    for _, langConfig := range c.LanguageLevels {
206
33x
        if langConfig.Code == language {
207
3x
            return langConfig.Levels
208
3x
        }
209
    }
210

211
3x
    return []string{}
212
}
213

214
// GetLevelDescriptionsForLanguage returns the level descriptions for a specific language
215
9x
func (c *Config) GetLevelDescriptionsForLanguage(language string) map[string]string {
216
9x
    if c.LanguageLevels == nil {
217
        return map[string]string{}
218
    }
219

220
    // First try to look up by language name directly
221
9x
    if langConfig, exists := c.LanguageLevels[language]; exists {
222
4x
        return langConfig.Descriptions
223
4x
    }
224

225
    // If not found by name, try to find by language code
226
5x
    for _, langConfig := range c.LanguageLevels {
227
14x
        if langConfig.Code == language {
228
2x
            return langConfig.Descriptions
229
2x
        }
230
    }
231

232
3x
    return map[string]string{}
233
}
234

235
// GetAllLevels returns all unique levels across all languages
236
4x
func (c *Config) GetAllLevels() []string {
237
4x
    if c.LanguageLevels == nil {
238
        return []string{}
239
    }
240

241
4x
    levelSet := make(map[string]bool)
242
4x
    for _, langConfig := range c.LanguageLevels {
243
22x
        for _, level := range langConfig.Levels {
244
146x
            levelSet[level] = true
245
146x
        }
246
    }
247

248
4x
    levels := make([]string, 0, len(levelSet))
249
4x
    for level := range levelSet {
250
46x
        levels = append(levels, level)
251
46x
    }
252

253
4x
    sort.Strings(levels)
254
4x
    return levels
255
}
256

257
// GetAllLevelDescriptions returns all unique level descriptions across all languages
258
4x
func (c *Config) GetAllLevelDescriptions() map[string]string {
259
4x
    if c.LanguageLevels == nil {
260
        return map[string]string{}
261
    }
262

263
4x
    descriptions := make(map[string]string)
264
4x
    for _, langConfig := range c.LanguageLevels {
265
22x
        for level, description := range langConfig.Descriptions {
266
142x
            descriptions[level] = description
267
142x
        }
268
    }
269

270
4x
    return descriptions
271
}
272

273
// Languages returns all supported languages
274
1x
func (c *Config) Languages() []string {
275
1x
    return c.GetLanguages()
276
1x
}
277

278
// Levels returns all unique levels
279
1x
func (c *Config) Levels() []string {
280
1x
    return c.GetAllLevels()
281
1x
}
282

283
// LevelDescriptions returns all unique level descriptions
284
1x
func (c *Config) LevelDescriptions() map[string]string {
285
1x
    return c.GetAllLevelDescriptions()
286
1x
}
287

288
// IsSignupDisabled returns whether signups are disabled based on configuration
289
4x
func (c *Config) IsSignupDisabled() bool {
290
4x
    if c.System == nil {
291
1x
        return false // Default to enabled if no config
292
1x
    }
293
3x
    return c.System.Auth.SignupsDisabled
294
}
295

296
// IsEmailAllowed checks if an email is allowed for OAuth signup override
297
19x
func (c *Config) IsEmailAllowed(email string) bool {
298
19x
    if c.System == nil || c.System.Auth.AllowedEmails == nil {
299
4x
        return false
300
4x
    }
301

302
15x
    normalizedEmail := strings.ToLower(strings.TrimSpace(email))
303
15x
    for _, allowedEmail := range c.System.Auth.AllowedEmails {
304
16x
        if strings.ToLower(strings.TrimSpace(allowedEmail)) == normalizedEmail {
305
6x
            return true
306
6x
        }
307
    }
308
9x
    return false
309
}
310

311
// IsDomainAllowed checks if a domain is allowed for OAuth signup override
312
16x
func (c *Config) IsDomainAllowed(domain string) bool {
313
16x
    if c.System == nil || c.System.Auth.AllowedDomains == nil {
314
4x
        return false
315
4x
    }
316

317
12x
    normalizedDomain := strings.ToLower(strings.TrimSpace(domain))
318
12x
    for _, allowedDomain := range c.System.Auth.AllowedDomains {
319
13x
        if strings.ToLower(strings.TrimSpace(allowedDomain)) == normalizedDomain {
320
6x
            return true
321
6x
        }
322
    }
323
6x
    return false
324
}
325

326
// IsOAuthSignupAllowed checks if OAuth signup is allowed for a given email
327
17x
func (c *Config) IsOAuthSignupAllowed(email string) bool {
328
17x
    if c.System == nil {
329
1x
        return false
330
1x
    }
331

332
    // If signups are not disabled, OAuth signup is always allowed
333
16x
    if !c.System.Auth.SignupsDisabled {
334
2x
        return true
335
2x
    }
336

337
    // If signups are disabled, check whitelist
338
14x
    normalizedEmail := strings.ToLower(strings.TrimSpace(email))
339
14x

340
14x
    // Use the shared email validation function
341
14x
    if !contextutils.IsValidEmail(normalizedEmail) {
342
4x
        return false
343
4x
    }
344

345
    // Check if email is directly whitelisted
346
10x
    if c.IsEmailAllowed(normalizedEmail) {
347
2x
        return true
348
2x
    }
349

350
    // Extract domain from email and check if domain is whitelisted
351
8x
    parts := strings.Split(normalizedEmail, "@")
352
8x
    domain := parts[1]
353
8x
    return c.IsDomainAllowed(domain)
354
}
355

356
// OpenTelemetryConfig holds all OpenTelemetry-related configuration
357
type OpenTelemetryConfig struct {
358
    Endpoint       string            `json:"endpoint" yaml:"endpoint"`               // Default: "http://localhost:4317"
359
    Protocol       string            `json:"protocol" yaml:"protocol"`               // "grpc" or "http", default: "grpc"
360
    Insecure       bool              `json:"insecure" yaml:"insecure"`               // Default: true (for localhost)
361
    Headers        map[string]string `json:"headers" yaml:"headers"`                 // For authenticated endpoints
362
    ServiceName    string            `json:"service_name" yaml:"service_name"`       // Default: "quiz-backend" or "quiz-worker"
363
    ServiceVersion string            `json:"service_version" yaml:"service_version"` // From version package
364
    EnableTracing  bool              `json:"enable_tracing" yaml:"enable_tracing"`   // Default: true
365
    EnableMetrics  bool              `json:"enable_metrics" yaml:"enable_metrics"`   // Default: true
366
    EnableLogging  bool              `json:"enable_logging" yaml:"enable_logging"`   // Default: true (future)
367
    SamplingRate   float64           `json:"sampling_rate" yaml:"sampling_rate"`     // Default: 1.0 (100%)
368
}
369

370
// DatabaseConfig represents database configuration
371
type DatabaseConfig struct {
372
    URL             string        `json:"url" yaml:"url"`
373
    MaxOpenConns    int           `json:"max_open_conns" yaml:"max_open_conns"`       // Maximum number of open connections to the database
374
    MaxIdleConns    int           `json:"max_idle_conns" yaml:"max_idle_conns"`       // Maximum number of idle connections in the pool
375
    ConnMaxLifetime time.Duration `json:"conn_max_lifetime" yaml:"conn_max_lifetime"` // Maximum amount of time a connection may be reused
376
}
377

378
// EmailConfig represents email/SMTP configuration
379
type EmailConfig struct {
380
    SMTP          SMTPConfig          `json:"smtp" yaml:"smtp"`
381
    DailyReminder DailyReminderConfig `json:"daily_reminder" yaml:"daily_reminder"`
382
    Enabled       bool                `json:"enabled" yaml:"enabled"`
383
}
384

385
// SMTPConfig represents SMTP server configuration
386
type SMTPConfig struct {
387
    Host        string `json:"host" yaml:"host"`
388
    Port        int    `json:"port" yaml:"port"`
389
    Username    string `json:"username" yaml:"username"`
390
    Password    string `json:"password" yaml:"password"`
391
    FromAddress string `json:"from_address" yaml:"from_address"`
392
    FromName    string `json:"from_name" yaml:"from_name"`
393
}
394

395
// DailyReminderConfig represents daily reminder email configuration
396
type DailyReminderConfig struct {
397
    Enabled bool `json:"enabled" yaml:"enabled"`
398
    Hour    int  `json:"hour" yaml:"hour"` // Hour of day to send (0-23)
399
}
400

401
// StorySectionLengthsConfig represents section length configuration by proficiency level
402
type StorySectionLengthsConfig struct {
403
    Beginner          map[string]int                       `json:"beginner" yaml:"beginner"`
404
    Elementary        map[string]int                       `json:"elementary" yaml:"elementary"`
405
    Intermediate      map[string]int                       `json:"intermediate" yaml:"intermediate"`
406
    UpperIntermediate map[string]int                       `json:"upper_intermediate" yaml:"upper_intermediate"`
407
    Advanced          map[string]int                       `json:"advanced" yaml:"advanced"`
408
    Proficient        map[string]int                       `json:"proficient" yaml:"proficient"`
409
    Overrides         map[string]map[string]map[string]int `json:"overrides" yaml:"overrides"`
410
}
411

412
// StoryConfig represents story mode configuration
413
type StoryConfig struct {
414
    MaxArchivedPerUser         int                       `json:"max_archived_per_user" yaml:"max_archived_per_user"`
415
    GenerationEnabled          bool                      `json:"generation_enabled" yaml:"generation_enabled"`
416
    EngagementBasedGeneration  bool                      `json:"engagement_based_generation" yaml:"engagement_based_generation"`
417
    SectionLengths             StorySectionLengthsConfig `json:"section_lengths" yaml:"section_lengths"`
418
    QuestionsPerSection        int                       `json:"questions_per_section" yaml:"questions_per_section"`
419
    MaxExtraGenerationsPerDay  int                       `json:"max_extra_generations_per_day" yaml:"max_extra_generations_per_day"`
420
    MaxWorkerGenerationsPerDay int                       `json:"max_worker_generations_per_day" yaml:"max_worker_generations_per_day"`
421
}
422

423
// TranslationConfig represents translation service configuration
424
type TranslationConfig struct {
425
    Enabled         bool                                 `json:"enabled" yaml:"enabled"`
426
    DefaultProvider string                               `json:"default_provider" yaml:"default_provider"`
427
    Providers       map[string]TranslationProviderConfig `json:"providers" yaml:"providers"`
428
    Quota           TranslationQuotaConfig               `json:"quota" yaml:"quota"`
429
}
430

431
// TranslationProviderConfig represents a translation provider configuration
432
type TranslationProviderConfig struct {
433
    Name          string `json:"name" yaml:"name"`
434
    Code          string `json:"code" yaml:"code"`
435
    APIKey        string `json:"api_key" yaml:"api_key"`
436
    BaseURL       string `json:"base_url" yaml:"base_url"`
437
    APIEndpoint   string `json:"api_endpoint" yaml:"api_endpoint"`
438
    MaxTextLength int    `json:"max_text_length" yaml:"max_text_length"`
439
}
440

441
// TranslationQuotaConfig represents quota configuration for translation services
442
type TranslationQuotaConfig struct {
443
    Enabled bool `json:"enabled" yaml:"enabled"`
444
    // Monthly character quotas per provider
445
    GoogleMonthlyQuota int64 `json:"google_monthly_quota" yaml:"google_monthly_quota"`
446
    // Default monthly quota for new providers (in characters)
447
    DefaultMonthlyQuota int64 `json:"default_monthly_quota" yaml:"default_monthly_quota"`
448
}
449

450
// LinearConfig represents Linear integration configuration
451
type LinearConfig struct {
452
    APIKey        string   `json:"api_key" yaml:"api_key"`               // API key from LINEAR_API_KEY env var
453
    TeamID        string   `json:"team_id" yaml:"team_id"`               // Team ID, override via LINEAR_TEAM_ID
454
    ProjectID     string   `json:"project_id" yaml:"project_id"`         // Project ID, override via LINEAR_PROJECT_ID
455
    DefaultLabels []string `json:"default_labels" yaml:"default_labels"` // Optional default labels
456
    DefaultState  string   `json:"default_state" yaml:"default_state"`   // Optional default state (e.g., "Todo")
457
    Enabled       bool     `json:"enabled" yaml:"enabled"`               // Feature flag
458
}
459

460
// NewConfig loads configuration from YAML file first, then overrides with environment variables
461
32x
func NewConfig() (result0 *Config, err error) {
462
32x
    // Load config from YAML file
463
32x
    config, err := loadConfigWithOverrides()
464
32x
    if err != nil {
465
3x
        return nil, contextutils.WrapErrorf(contextutils.ErrInternalError, "failed to load config: %v", err)
466
3x
    }
467

468
    // Override with environment variables
469
29x
    config.overrideFromEnv()
470
29x

471
29x
    return config, nil
472
}
473

474
// overrideFromEnv overrides config values with environment variables using reflection
475
29x
func (c *Config) overrideFromEnv() {
476
29x
    overrideStructFromEnv(c)
477
29x
}
478

479
// overrideStructFromEnv recursively overrides struct fields with environment variables
480
37x
func overrideStructFromEnv(v interface{}) {
481
37x
    overrideStructFromEnvWithPrefix(v, "")
482
37x
}
483

484
// overrideStructFromEnvWithPrefix recursively overrides struct fields with environment variables
485
489x
func overrideStructFromEnvWithPrefix(v interface{}, prefix string) {
486
489x
    val := reflect.ValueOf(v)
487
489x
    if val.Kind() == reflect.Ptr {
488
489x
        val = val.Elem()
489
489x
    }
490

491
489x
    if val.Kind() != reflect.Struct {
492
        return
493
    }
494

495
489x
    typ := val.Type()
496
489x
    for i := 0; i < val.NumField(); i++ {
497
3387x
        field := val.Field(i)
498
3387x
        fieldType := typ.Field(i)
499
3387x

500
3387x
        // Skip unexported fields
501
3387x
        if !field.CanSet() {
502
            continue
503
        }
504

505
        // Get the yaml tag for the field
506
3387x
        yamlTag := fieldType.Tag.Get("yaml")
507
3387x
        if yamlTag == "" || yamlTag == "-" {
508
            continue
509
        }
510

511
        // Convert yaml tag to environment variable name
512
3387x
        envKey := strings.ToUpper(strings.ReplaceAll(yamlTag, "-", "_"))
513
3387x
        if prefix != "" {
514
2832x
            envKey = prefix + "_" + envKey
515
2832x
        }
516

517
3387x
        switch field.Kind() {
518
1036x
        case reflect.String:
519
1036x
            if envVal := os.Getenv(envKey); envVal != "" {
520
246x
                field.SetString(envVal)
521
246x
            }
522
666x
        case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
523
666x
            if envVal := os.Getenv(envKey); envVal != "" {
524
9x
                if intVal, err := strconv.ParseInt(envVal, 10, 64); err == nil {
525
6x
                    field.SetInt(intVal)
526
6x
                }
527
            }
528
        case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
529
            if envVal := os.Getenv(envKey); envVal != "" {
530
                if uintVal, err := strconv.ParseUint(envVal, 10, 64); err == nil {
531
                    field.SetUint(uintVal)
532
                }
533
            }
534
74x
        case reflect.Float32, reflect.Float64:
535
74x
            if envVal := os.Getenv(envKey); envVal != "" {
536
3x
                if floatVal, err := strconv.ParseFloat(envVal, 64); err == nil {
537
2x
                    field.SetFloat(floatVal)
538
2x
                }
539
            }
540
497x
        case reflect.Bool:
541
497x
            if envVal := os.Getenv(envKey); envVal != "" {
542
70x
                if boolVal, err := strconv.ParseBool(envVal); err == nil {
543
68x
                    field.SetBool(boolVal)
544
68x
                }
545
            }
546
234x
        case reflect.Slice:
547
234x
            if envVal := os.Getenv(envKey); envVal != "" {
548
3x
                // Handle string slices (like CORS_ORIGINS)
549
3x
                if field.Type().Elem().Kind() == reflect.String {
550
3x
                    slice := strings.Split(envVal, ",")
551
3x
                    field.Set(reflect.ValueOf(slice))
552
3x
                }
553
            }
554
383x
        case reflect.Map:
555
383x
            // Handle map fields with string keys and struct values
556
383x
            if field.Type().Key().Kind() == reflect.String && field.Type().Elem().Kind() == reflect.Struct {
557
74x
                handleMapFieldOverrides(field, yamlTag, prefix)
558
74x
            }
559
423x
        case reflect.Struct:
560
423x
            // Recursively process nested structs with the field name as prefix
561
423x
            if field.CanAddr() {
562
423x
                fieldPrefix := strings.ToUpper(strings.ReplaceAll(yamlTag, "-", "_"))
563
423x
                if prefix != "" {
564
164x
                    fieldPrefix = prefix + "_" + fieldPrefix
565
164x
                }
566
423x
                overrideStructFromEnvWithPrefix(field.Addr().Interface(), fieldPrefix)
567
            }
568
74x
        case reflect.Ptr:
569
74x
            // Handle pointer to struct
570
74x
            if !field.IsNil() && field.Elem().Kind() == reflect.Struct {
571
29x
                fieldPrefix := strings.ToUpper(strings.ReplaceAll(yamlTag, "-", "_"))
572
29x
                if prefix != "" {
573
                    fieldPrefix = prefix + "_" + fieldPrefix
574
                }
575
29x
                overrideStructFromEnvWithPrefix(field.Interface(), fieldPrefix)
576
            }
577
        }
578
    }
579
}
580

581
// handleMapFieldOverrides handles environment variable overrides for map fields with string keys and struct values
582
74x
func handleMapFieldOverrides(field reflect.Value, yamlTag, parentPrefix string) {
583
74x
    if !field.CanSet() || field.Type().Key().Kind() != reflect.String {
584
        return
585
    }
586

587
    // Build the prefix for environment variables
588
74x
    mapPrefix := strings.ToUpper(strings.ReplaceAll(yamlTag, "-", "_"))
589
74x
    if parentPrefix != "" {
590
37x
        mapPrefix = parentPrefix + "_" + mapPrefix
591
37x
    }
592

593
    // Iterate through all keys in the map and look for corresponding environment variables
594
74x
    for _, key := range field.MapKeys() {
595
146x
        keyName := key.String()
596
146x
        keyVal := field.MapIndex(key)
597
146x

598
146x
        if keyVal.IsValid() && keyVal.Kind() == reflect.Struct {
599
146x
            // Create a new struct with potential overrides
600
146x
            newStruct := createStructWithOverrides(keyVal, keyName, mapPrefix)
601
146x
            if newStruct.IsValid() {
602
13x
                field.SetMapIndex(key, newStruct)
603
13x
            }
604
        }
605
    }
606
}
607

608
// createStructWithOverrides creates a new struct with environment variable overrides applied
609
146x
func createStructWithOverrides(originalStruct reflect.Value, keyName, mapPrefix string) reflect.Value {
610
146x
    if !originalStruct.IsValid() || originalStruct.Kind() != reflect.Struct {
611
        return reflect.Value{}
612
    }
613

614
146x
    structType := originalStruct.Type()
615
146x
    newStruct := reflect.New(structType).Elem()
616
146x
    updated := false
617
146x

618
146x
    for i := 0; i < structType.NumField(); i++ {
619
756x
        fieldInfo := structType.Field(i)
620
756x
        origField := originalStruct.Field(i)
621
756x
        newField := newStruct.Field(i)
622
756x

623
756x
        // Skip unexported fields
624
756x
        if !newField.CanSet() {
625
            continue
626
        }
627

628
        // Get the yaml tag for the field
629
756x
        yamlTag := fieldInfo.Tag.Get("yaml")
630
756x
        if yamlTag == "" || yamlTag == "-" {
631
            // Copy original value for fields without yaml tags
632
            newField.Set(origField)
633
            continue
634
        }
635

636
        // Convert yaml tag to environment variable name
637
756x
        envKey := strings.ToUpper(strings.ReplaceAll(yamlTag, "-", "_"))
638
756x
        envVarName := fmt.Sprintf("%s_%s_%s", mapPrefix, strings.ToUpper(keyName), envKey)
639
756x

640
756x
        envVal := os.Getenv(envVarName)
641
756x
        if envVal != "" {
642
13x
            // Set the field value based on its type
643
13x
            setReflectValue(newField, envVal)
644
13x
            updated = true
645
13x
        } else {
646
743x
            // Copy the original value
647
743x
            newField.Set(origField)
648
743x
        }
649
    }
650

651
146x
    if updated {
652
13x
        return newStruct
653
13x
    }
654
133x
    return reflect.Value{}
655
}
656

657
// setReflectValue sets a reflect.Value from a string environment variable
658
13x
func setReflectValue(field reflect.Value, envVal string) {
659
13x
    if !field.CanSet() {
660
        return
661
    }
662

663
13x
    switch field.Kind() {
664
13x
    case reflect.String:
665
13x
        field.SetString(envVal)
666
    case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
667
        if intVal, err := strconv.ParseInt(envVal, 10, 64); err == nil {
668
            field.SetInt(intVal)
669
        }
670
    case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
671
        if uintVal, err := strconv.ParseUint(envVal, 10, 64); err == nil {
672
            field.SetUint(uintVal)
673
        }
674
    case reflect.Float32, reflect.Float64:
675
        if floatVal, err := strconv.ParseFloat(envVal, 64); err == nil {
676
            field.SetFloat(floatVal)
677
        }
678
    case reflect.Bool:
679
        if boolVal, err := strconv.ParseBool(envVal); err == nil {
680
            field.SetBool(boolVal)
681
        }
682
    }
683
}
684

685
// loadConfigWithOverrides loads the config file with potential local overrides
686
32x
func loadConfigWithOverrides() (result0 *Config, err error) {
687
32x
    // Try to load from environment variable first
688
32x
    if envPath := os.Getenv("QUIZ_CONFIG_FILE"); envPath != "" {
689
32x
        config, err := loadConfigFromFile(envPath)
690
32x
        if err != nil {
691
3x
            return nil, contextutils.WrapErrorf(contextutils.ErrInternalError, "failed to load config from %s: %v", envPath, err)
692
3x
        }
693
29x
        return config, nil
694
    }
695

696
    // If no environment variable is set, try default config.yaml
697
    return loadConfigFromFile("config.yaml")
698
}
699

700
// loadConfigFromFile loads configuration from a specific file
701
32x
func loadConfigFromFile(path string) (result0 *Config, err error) {
702
32x
    yamlFile, err := os.ReadFile(path)
703
32x
    if err != nil {
704
3x
        return nil, err
705
3x
    }
706

707
29x
    var config Config
708
29x
    if err := yaml.Unmarshal(yamlFile, &config); err != nil {
709
        return nil, err
710
    }
711

712
29x
    return &config, nil
713
}
714


			
quizapp internal database
81.5%
Statements
181/222
database.go
81.5%
181/222
quizapp internal database database.go
81.5%
Statements
181/222
1
// Package database provides database connection and migration functionality.
2
package database
3

4
import (
5
    "context"
6
    "database/sql"
7
    "errors"
8
    "fmt"
9
    "net/url"
10
    "os"
11
    "path/filepath"
12
    "strings"
13
    "sync"
14

15
    "quizapp/internal/config"
16
    "quizapp/internal/observability"
17
    contextutils "quizapp/internal/utils"
18

19
    // Import PostgreSQL driver for database/sql
20
    _ "github.com/lib/pq"
21

22
    // Add golang-migrate imports
23
    "github.com/golang-migrate/migrate/v4"
24
    _ "github.com/golang-migrate/migrate/v4/database/postgres" // required for golang-migrate postgres driver
25
    _ "github.com/golang-migrate/migrate/v4/source/file"       // required for golang-migrate file source
26

27
    // OpenTelemetry SQL instrumentation
28
    "go.nhat.io/otelsql"
29

30
    "go.opentelemetry.io/otel/attribute"
31
    semconv "go.opentelemetry.io/otel/semconv/v1.4.0"
32
)
33

34
// Manager handles database operations with proper logging
35
type Manager struct {
36
    logger *observability.Logger
37
}
38

39
var (
40
    otelDriverNameCache string
41
    otelDriverOnce      sync.Once
42
    otelDriverErr       error
43
)
44

45
// NewManager creates a new database manager with the provided logger
46
18x
func NewManager(logger *observability.Logger) *Manager {
47
18x
    return &Manager{
48
18x
        logger: logger,
49
18x
    }
50
18x
}
51

52
// ErrTableAlreadyExists is returned when trying to create a table that already exists
53
var ErrTableAlreadyExists = errors.New("table already exists")
54

55
// DefaultDatabaseConfig returns the default database configuration
56
11x
func DefaultDatabaseConfig() config.DatabaseConfig {
57
11x
    config := config.DatabaseConfig{
58
11x
        MaxOpenConns:    25,
59
11x
        MaxIdleConns:    5,
60
11x
        ConnMaxLifetime: config.DatabaseConnMaxLifetime,
61
11x
    }
62
11x

63
11x
    // Check for TEST_DATABASE_URL first (for tests)
64
11x
    if testURL := os.Getenv("TEST_DATABASE_URL"); testURL != "" {
65
11x
        config.URL = testURL
66
11x
    }
67

68
11x
    return config
69
}
70

71
// InitDB initializes and returns a database connection with migrations
72
4x
func (dm *Manager) InitDB(databaseURL string) (result0 *sql.DB, err error) {
73
4x
    dbName := extractDatabaseName(databaseURL)
74
4x
    _, span := observability.TraceDatabaseFunction(context.Background(), "InitDB",
75
4x
        attribute.String("db.url", databaseURL),
76
4x
        attribute.String("db.name", dbName),
77
4x
        attribute.String("db.system", "postgresql"),
78
4x
        attribute.Bool("migrations.enabled", true),
79
4x
    )
80
4x
    defer observability.FinishSpan(span, &err)
81
4x
    config := DefaultDatabaseConfig()
82
4x
    config.URL = databaseURL
83
4x
    return dm.InitDBWithConfig(config)
84
4x
}
85

86
// InitDBWithConfig initializes and returns a database connection with migrations and custom config
87
4x
func (dm *Manager) InitDBWithConfig(config config.DatabaseConfig) (result0 *sql.DB, err error) {
88
4x
    dbName := extractDatabaseName(config.URL)
89
4x
    _, span := observability.TraceDatabaseFunction(context.Background(), "InitDBWithConfig",
90
4x
        attribute.String("db.url", config.URL),
91
4x
        attribute.String("db.name", dbName),
92
4x
        attribute.String("db.system", "postgresql"),
93
4x
        attribute.Bool("migrations.enabled", true),
94
4x
        attribute.Int("db.max_open_conns", config.MaxOpenConns),
95
4x
        attribute.Int("db.max_idle_conns", config.MaxIdleConns),
96
4x
        attribute.String("db.conn_max_lifetime", config.ConnMaxLifetime.String()),
97
4x
    )
98
4x
    defer observability.FinishSpan(span, &err)
99
4x
    db, err := dm.InitDBWithoutMigrations(config)
100
4x
    if err != nil {
101
1x
        return nil, err
102
1x
    }
103

104
2x
    if err := dm.RunMigrations(db); err != nil {
105
        return nil, err
106
    }
107

108
2x
    return db, nil
109
}
110

111
// extractDatabaseName extracts the database name from a PostgreSQL connection string
112
17x
func extractDatabaseName(databaseURL string) string {
113
17x
    // Try to parse as URL first
114
17x
    if u, err := url.Parse(databaseURL); err == nil && u.Path != "" {
115
15x
        // Remove leading slash and return the database name
116
15x
        dbName := strings.TrimPrefix(u.Path, "/")
117
15x
        if dbName != "" {
118
14x
            return dbName
119
14x
        }
120
    }
121

122
    // Fallback: try to extract from connection string format
123
    // postgres://user:pass@host:port/dbname?sslmode=disable
124
3x
    if strings.Contains(databaseURL, "/") {
125
2x
        parts := strings.Split(databaseURL, "/")
126
2x
        if len(parts) > 1 {
127
2x
            // Get the last part and remove query parameters
128
2x
            dbPart := parts[len(parts)-1]
129
2x
            if idx := strings.Index(dbPart, "?"); idx != -1 {
130
1x
                return dbPart[:idx]
131
1x
            }
132
1x
            return dbPart
133
        }
134
    }
135

136
    // Default fallback
137
1x
    return "quiz_db"
138
}
139

140
// InitDBWithoutMigrations initializes and returns a database connection without running migrations
141
11x
func (dm *Manager) InitDBWithoutMigrations(config config.DatabaseConfig) (result0 *sql.DB, err error) {
142
11x
    // Extract database name for OpenTelemetry tracing
143
11x
    ctx, span := observability.TraceDatabaseFunction(context.Background(), "InitDBWithoutMigrations",
144
11x
        attribute.String("database.url", config.URL),
145
11x
    )
146
11x
    defer observability.FinishSpan(span, &err)
147
11x

148
11x
    // Register OpenTelemetry SQL driver once per process and reuse the name
149
11x
    otelDriverOnce.Do(func() {
150
1x
        otelDriverNameCache, otelDriverErr = otelsql.Register("postgres",
151
1x
            otelsql.WithDatabaseName(extractDatabaseName(config.URL)),
152
1x
            otelsql.TraceQueryWithArgs(),
153
1x
            otelsql.WithSystem(semconv.DBSystemPostgreSQL),
154
1x
            otelsql.TraceRowsAffected(),
155
1x
        )
156
1x
    })
157
11x
    if otelDriverErr != nil {
158
        return nil, contextutils.WrapError(otelDriverErr, "failed to register otelsql driver")
159
    }
160

161
    // Connect to database using the instrumented driver
162
11x
    db, err := sql.Open(otelDriverNameCache, config.URL)
163
11x
    if err != nil {
164
        return nil, contextutils.WrapError(err, "failed to open database connection")
165
    }
166

167
    // Set connection pool settings
168
11x
    db.SetMaxOpenConns(config.MaxOpenConns)
169
11x
    db.SetMaxIdleConns(config.MaxIdleConns)
170
11x
    db.SetConnMaxLifetime(config.ConnMaxLifetime)
171
11x

172
11x
    // Test the connection
173
11x
    if err := db.Ping(); err != nil {
174
1x
        if closeErr := db.Close(); closeErr != nil {
175
            dm.logger.Error(ctx, "Failed to close database connection after ping failure", closeErr)
176
        }
177
1x
        return nil, contextutils.WrapError(err, "failed to ping database")
178
    }
179

180
10x
    dm.logger.Info(ctx, "Database connection established without migrations", map[string]interface{}{
181
10x
        "max_open_conns":    config.MaxOpenConns,
182
10x
        "max_idle_conns":    config.MaxIdleConns,
183
10x
        "conn_max_lifetime": config.ConnMaxLifetime,
184
10x
    })
185
10x

186
10x
    return db, nil
187
}
188

189
// RunMigrations executes the application SQL schema and any pending migrations
190
4x
func (dm *Manager) RunMigrations(db *sql.DB) (err error) {
191
4x
    _, span := observability.TraceDatabaseFunction(context.Background(), "RunMigrations",
192
4x
        attribute.String("db.system", "postgresql"),
193
4x
        attribute.String("migration.type", "application_schema"),
194
4x
    )
195
4x
    defer observability.FinishSpan(span, &err)
196
4x
    dm.logger.Info(context.Background(), "Starting database migrations...")
197
4x

198
4x
    // Run the main application schema first
199
4x
    if err := dm.runApplicationSchema(db); err != nil {
200
        return contextutils.WrapError(err, "failed to run application schema")
201
    }
202
4x
    dm.logger.Info(context.Background(), "Application schema applied successfully")
203
4x

204
4x
    // Run golang-migrate migrations if directory exists
205
4x
    if err := dm.runGolangMigrate(); err != nil {
206
        return contextutils.WrapError(err, "failed to run golang-migrate migrations")
207
    }
208

209
4x
    dm.logger.Info(context.Background(), "Database migrations completed successfully")
210
4x
    return nil
211
}
212

213
// runGolangMigrate runs migrations using golang-migrate from migrations
214
8x
func (dm *Manager) runGolangMigrate() (err error) {
215
8x
    migrationsPath, err := dm.GetMigrationsPath()
216
8x
    if err != nil {
217
1x
        dm.logger.Error(context.Background(), "Could not find migrations path", err)
218
1x
        return err // HARD FAIL if migrations path is not set
219
1x
    }
220

221
7x
    _, span := observability.TraceDatabaseFunction(context.Background(), "runGolangMigrate",
222
7x
        attribute.String("db.system", "postgresql"),
223
7x
        attribute.String("migration.type", "golang_migrate"),
224
7x
        attribute.String("migration.path", migrationsPath),
225
7x
    )
226
7x
    defer observability.FinishSpan(span, &err)
227
7x

228
7x
    if migrationsPath == "" {
229
        err = errors.New("no golang-migrate migrations directory found")
230
        dm.logger.Error(context.Background(), "No golang-migrate migrations directory found, hard fail!", err)
231
        return err // HARD FAIL
232
    }
233

234
    // Check if migrations directory exists and has migration files
235
7x
    if _, statErr := os.Stat(migrationsPath); os.IsNotExist(statErr) {
236
        dm.logger.Error(context.Background(), "Migrations directory does not exist", statErr)
237
        err = statErr // HARD FAIL if directory does not exist
238
        return err
239
    }
240

241
    // Check if there are any migration files in the directory
242
7x
    files, err := os.ReadDir(migrationsPath)
243
7x
    if err != nil {
244
        dm.logger.Error(context.Background(), "Could not read migrations directory", err)
245
        return err // HARD FAIL
246
    }
247

248
    // Check if there are any .up.sql files
249
7x
    hasMigrationFiles := false
250
7x
    migrationFileCount := 0
251
7x
    for _, file := range files {
252
450x
        if !file.IsDir() && strings.HasSuffix(file.Name(), ".up.sql") {
253
222x
            hasMigrationFiles = true
254
222x
            migrationFileCount++
255
222x
        }
256
    }
257

258
7x
    span.SetAttributes(attribute.Int("migration.files.count", migrationFileCount))
259
7x

260
7x
    if !hasMigrationFiles {
261
1x
        dm.logger.Info(context.Background(), fmt.Sprintf("No migration files found in %s. Skipping golang-migrate.", migrationsPath))
262
1x
        return nil
263
1x
    }
264

265
6x
    dbURL := os.Getenv("DATABASE_URL")
266
6x
    if dbURL == "" {
267
5x
        dbURL = os.Getenv("TEST_DATABASE_URL")
268
5x
    }
269
6x
    if dbURL == "" {
270
1x
        err = errors.New("database_url or test_database_url must be set for migrations")
271
1x
        return err
272
1x
    }
273

274
    // Use file:// scheme with absolute path for golang-migrate
275
    // Convert to file:// URL format - use absolute path
276
5x
    migrationSourceURL := "file://" + filepath.ToSlash(migrationsPath)
277
5x

278
5x
    // Debug logging
279
5x
    dm.logger.Info(context.Background(), "Migration paths", map[string]interface{}{
280
5x
        "migrations_path": migrationsPath,
281
5x
        "source_url":      migrationSourceURL,
282
5x
        "db_url":          dbURL,
283
5x
    })
284
5x

285
5x
    m, err := migrate.New(
286
5x
        migrationSourceURL,
287
5x
        dbURL,
288
5x
    )
289
5x
    if err != nil {
290
1x
        err = contextutils.WrapError(err, "failed to initialize golang-migrate")
291
1x
        return err
292
1x
    }
293
4x
    defer func() {
294
4x
        if _, closeErr := m.Close(); closeErr != nil {
295
            dm.logger.Error(context.Background(), "Error closing migration", closeErr)
296
        }
297
    }()
298

299
4x
    err = m.Up()
300
4x
    if err != nil && err != migrate.ErrNoChange {
301
        err = contextutils.WrapError(err, "golang-migrate up failed")
302
        return err
303
    }
304
4x
    if err == migrate.ErrNoChange {
305
4x
        dm.logger.Info(context.Background(), "No new golang-migrate migrations to apply.")
306
4x
    } else {
307
        dm.logger.Info(context.Background(), "golang-migrate migrations applied successfully.")
308
    }
309
4x
    return nil
310
}
311

312
// runApplicationSchema executes the main application schema.sql
313
7x
func (dm *Manager) runApplicationSchema(db *sql.DB) (err error) {
314
7x
    schemaPath, err := dm.getSchemaPath()
315
7x
    if err != nil {
316
        err = contextutils.WrapError(err, "failed to find schema file")
317
        return err
318
    }
319

320
7x
    _, span := observability.TraceDatabaseFunction(context.Background(), "runApplicationSchema",
321
7x
        attribute.String("db.system", "postgresql"),
322
7x
        attribute.String("migration.type", "application_schema"),
323
7x
        attribute.String("schema.path", schemaPath),
324
7x
    )
325
7x
    defer observability.FinishSpan(span, &err)
326
7x
    // Get the schema file path relative to the project root
327
7x
    schemaPath, err = dm.getSchemaPath()
328
7x
    if err != nil {
329
        err = contextutils.WrapError(err, "failed to find schema file")
330
        return err
331
    }
332

333
    // Read the schema file
334
7x
    schemaSQL, err := os.ReadFile(schemaPath)
335
7x
    if err != nil {
336
        err = contextutils.WrapError(err, "failed to read schema file")
337
        return err
338
    }
339

340
7x
    span.SetAttributes(attribute.Int("schema.file.size", len(schemaSQL)))
341
7x

342
7x
    // Parse SQL statements more carefully to handle comments and multi-line statements
343
7x
    statements := dm.parseSchemaStatements(string(schemaSQL))
344
7x

345
7x
    span.SetAttributes(attribute.Int("schema.statements.count", len(statements)))
346
7x

347
7x
    // Execute table creation statements first
348
7x
    var indexStatements []string
349
7x
    for _, statement := range statements {
350
691x
        statement = strings.TrimSpace(statement)
351
691x
        if statement == "" {
352
            continue
353
        }
354

355
        // Separate index creation from table creation
356
691x
        if strings.HasPrefix(strings.ToUpper(statement), "CREATE INDEX") {
357
479x
            indexStatements = append(indexStatements, statement)
358
479x
            continue
359
        }
360

361
212x
        _, execErr := db.Exec(statement)
362
212x
        if execErr != nil {
363
1x
            // For backwards compatibility, ignore table exists errors
364
1x
            if !dm.isTableExistsError(execErr) {
365
                err = contextutils.WrapErrorf(execErr, "failed to execute schema statement: %s", statement)
366
                return err
367
            }
368
        }
369
    }
370

371
7x
    span.SetAttributes(attribute.Int("schema.index_statements.count", len(indexStatements)))
372
7x

373
7x
    // Now execute index creation statements
374
7x
    for _, statement := range indexStatements {
375
479x
        _, execErr := db.Exec(statement)
376
479x
        if execErr != nil {
377
3x
            // For backwards compatibility, ignore index exists and column exists errors
378
3x
            if !dm.isTableExistsError(execErr) && !dm.isColumnExistsError(execErr) {
379
                err = contextutils.WrapErrorf(execErr, "failed to execute index statement: %s", statement)
380
                return err
381
            }
382
        }
383
    }
384

385
7x
    return nil
386
}
387

388
// getSchemaPath finds the schema.sql file relative to the project root
389
16x
func (dm *Manager) getSchemaPath() (result0 string, err error) {
390
16x
    _, span := observability.TraceDatabaseFunction(context.Background(), "getSchemaPath",
391
16x
        attribute.String("file.name", "schema.sql"),
392
16x
    )
393
16x
    defer observability.FinishSpan(span, &err)
394
16x
    // Start from the current directory and work up to find schema.sql
395
16x
    currentDir, err := os.Getwd()
396
16x
    if err != nil {
397
        return "", err
398
    }
399

400
16x
    span.SetAttributes(attribute.String("search.start_dir", currentDir))
401
16x

402
16x
    for {
403
52x
        schemaPath := filepath.Join(currentDir, "schema.sql")
404
52x
        if _, statErr := os.Stat(schemaPath); statErr == nil {
405
16x
            span.SetAttributes(attribute.String("schema.found_path", schemaPath))
406
16x
            return schemaPath, nil
407
16x
        }
408

409
        // Move up one directory
410
36x
        parentDir := filepath.Dir(currentDir)
411
36x
        if parentDir == currentDir {
412
            // We've reached the root directory
413
            span.SetAttributes(attribute.String("search.result", "not_found"))
414
            err = contextutils.ErrorWithContextf("schema.sql not found in any parent directory")
415
            return "", err
416
        }
417
36x
        currentDir = parentDir
418
    }
419
}
420

421
// parseSchemaStatements parses SQL statements from a schema file
422
8x
func (dm *Manager) parseSchemaStatements(schemaSQL string) []string {
423
8x
    _, span := observability.TraceDatabaseFunction(context.Background(), "parseSchemaStatements",
424
8x
        attribute.Int("input.length", len(schemaSQL)),
425
8x
    )
426
8x
    defer span.End()
427
8x

428
8x
    // Remove comments and normalize whitespace
429
8x
    lines := strings.Split(schemaSQL, "\n")
430
8x
    var cleanedLines []string
431
8x
    inComment := false
432
8x

433
8x
    for _, line := range lines {
434
3846x
        line = strings.TrimSpace(line)
435
3846x

436
3846x
        // Skip empty lines
437
3846x
        if line == "" {
438
10x
            continue
439
        }
440

441
        // Handle multi-line comments
442
3836x
        if strings.HasPrefix(line, "/*") {
443
            inComment = true
444
            continue
445
        }
446
3836x
        if strings.HasSuffix(line, "*/") {
447
            inComment = false
448
            continue
449
        }
450
3836x
        if inComment {
451
            continue
452
        }
453

454
        // Skip single-line comments
455
3836x
        if strings.HasPrefix(line, "--") {
456
522x
            continue
457
        }
458

459
        // Remove inline comments (comments that appear after SQL code)
460
3314x
        if commentIndex := strings.Index(line, "--"); commentIndex != -1 {
461
            line = strings.TrimSpace(line[:commentIndex])
462
        }
463

464
3314x
        cleanedLines = append(cleanedLines, line)
465
    }
466

467
    // Join lines and split by semicolon
468
8x
    cleanedSQL := strings.Join(cleanedLines, " ")
469
8x
    statements := strings.Split(cleanedSQL, ";")
470
8x

471
8x
    var result []string
472
8x
    for _, stmt := range statements {
473
838x
        stmt = strings.TrimSpace(stmt)
474
838x
        if stmt != "" {
475
828x
            result = append(result, stmt)
476
828x
        }
477
    }
478

479
8x
    span.SetAttributes(attribute.Int("statements.parsed", len(result)))
480
8x
    return result
481
}
482

483
// isTableExistsError checks if the error is due to a table already existing
484
5x
func (dm *Manager) isTableExistsError(err error) bool {
485
5x
    _, span := observability.TraceDatabaseFunction(context.Background(), "isTableExistsError")
486
5x
    defer span.End()
487
5x
    // Check for the sentinel error first
488
5x
    if errors.Is(err, ErrTableAlreadyExists) {
489
        return true
490
    }
491
    // Fallback to string matching for backwards compatibility
492
5x
    return strings.Contains(err.Error(), "already exists")
493
}
494

495
// isColumnExistsError checks if the error is due to a column not existing (for index creation)
496
4x
func (dm *Manager) isColumnExistsError(err error) bool {
497
4x
    _, span := observability.TraceDatabaseFunction(context.Background(), "isColumnExistsError")
498
4x
    defer span.End()
499
4x
    return strings.Contains(err.Error(), "column") && strings.Contains(err.Error(), "does not exist")
500
4x
}
501

502
// GetMigrationsPath returns the path to the migrations directory
503
9x
func (dm *Manager) GetMigrationsPath() (result0 string, err error) {
504
9x
    _, span := observability.TraceDatabaseFunction(context.Background(), "GetMigrationsPath",
505
9x
        attribute.String("migration.dir.name", "migrations"),
506
9x
    )
507
9x
    defer observability.FinishSpan(span, &err)
508
9x
    // Start from the current directory and work up to find migrations directory
509
9x
    currentDir, err := os.Getwd()
510
9x
    if err != nil {
511
        return "", err
512
    }
513

514
9x
    span.SetAttributes(attribute.String("search.start_dir", currentDir))
515
9x

516
9x
    for {
517
26x
        migrationsPath := filepath.Join(currentDir, "migrations")
518
26x
        if _, statErr := os.Stat(migrationsPath); statErr == nil {
519
8x
            span.SetAttributes(attribute.String("migration.found_path", migrationsPath))
520
8x
            return migrationsPath, nil
521
8x
        }
522

523
        // Move up one directory
524
18x
        parentDir := filepath.Dir(currentDir)
525
18x
        if parentDir == currentDir {
526
1x
            // We've reached the root directory
527
1x
            span.SetAttributes(attribute.String("search.result", "not_found"))
528
1x
            err = contextutils.ErrorWithContextf("migrations directory not found in any parent directory")
529
1x
            return "", err
530
1x
        }
531
17x
        currentDir = parentDir
532
    }
533
}
534


			
quizapp internal di
95.8%
Statements
113/118
container.go
95.8%
113/118
quizapp internal di container.go
95.8%
Statements
113/118
1
// Package di provides dependency injection container for managing service lifecycle and dependencies.
2
package di
3

4
import (
5
    "context"
6
    "database/sql"
7
    "sync"
8

9
    "quizapp/internal/config"
10
    "quizapp/internal/database"
11
    "quizapp/internal/observability"
12
    "quizapp/internal/services"
13
    contextutils "quizapp/internal/utils"
14
)
15

16
// ServiceContainerInterface defines the interface for service containers
17
type ServiceContainerInterface interface {
18
    GetService(name string) (interface{}, error)
19
    GetUserService() (services.UserServiceInterface, error)
20
    GetQuestionService() (services.QuestionServiceInterface, error)
21
    GetLearningService() (services.LearningServiceInterface, error)
22
    GetAIService() (services.AIServiceInterface, error)
23
    GetWorkerService() (services.WorkerServiceInterface, error)
24
    GetDailyQuestionService() (services.DailyQuestionServiceInterface, error)
25
    GetStoryService() (services.StoryServiceInterface, error)
26
    GetOAuthService() (*services.OAuthService, error)
27
    GetGenerationHintService() (services.GenerationHintServiceInterface, error)
28
    GetConversationService() (services.ConversationServiceInterface, error)
29
    GetEmailService() (services.EmailServiceInterface, error)
30
    GetTranslationService() (services.TranslationServiceInterface, error)
31
    GetSnippetsService() (services.SnippetsServiceInterface, error)
32
    GetUsageStatsService() (services.UsageStatsServiceInterface, error)
33
    GetWordOfTheDayService() (services.WordOfTheDayServiceInterface, error)
34
    GetAuthAPIKeyService() (services.AuthAPIKeyServiceInterface, error)
35
    GetDatabase() *sql.DB
36
    GetConfig() *config.Config
37
    GetLogger() *observability.Logger
38
    Initialize(ctx context.Context) error
39
    Shutdown(ctx context.Context) error
40
    EnsureAdminUser(ctx context.Context) error
41
}
42

43
// ServiceContainer manages all service dependencies and lifecycle
44
type ServiceContainer struct {
45
    cfg           *config.Config
46
    logger        *observability.Logger
47
    dbManager     *database.Manager
48
    db            *sql.DB
49
    services      map[string]interface{}
50
    mu            sync.RWMutex
51
    shutdownFuncs []func(context.Context) error
52
}
53

54
// NewServiceContainer creates a new dependency injection container
55
10x
func NewServiceContainer(cfg *config.Config, logger *observability.Logger) *ServiceContainer {
56
10x
    return &ServiceContainer{
57
10x
        cfg:      cfg,
58
10x
        logger:   logger,
59
10x
        services: make(map[string]interface{}),
60
10x
    }
61
10x
}
62

63
// Initialize sets up all services and their dependencies
64
8x
func (sc *ServiceContainer) Initialize(ctx context.Context) error {
65
8x
    sc.mu.Lock()
66
8x
    defer sc.mu.Unlock()
67
8x

68
8x
    // Initialize database
69
8x
    sc.dbManager = database.NewManager(sc.logger)
70
8x
    db, err := sc.dbManager.InitDBWithConfig(sc.cfg.Database)
71
8x
    if err != nil {
72
1x
        return contextutils.WrapErrorf(err, "failed to initialize database")
73
1x
    }
74
7x
    sc.db = db
75
7x
    sc.shutdownFuncs = append(sc.shutdownFuncs, func(_ context.Context) error {
76
4x
        return db.Close()
77
4x
    })
78

79
    // Initialize core services
80
7x
    sc.initializeServices(ctx)
81
7x

82
7x
    // Startup lifecycle services
83
7x
    if err := sc.startupServices(ctx); err != nil {
84
        // Cleanup on failure
85
        _ = sc.cleanup(ctx)
86
        return contextutils.WrapErrorf(err, "failed to startup services")
87
    }
88

89
7x
    return nil
90
}
91

92
// GetService retrieves a service by name with type assertion
93
58x
func (sc *ServiceContainer) GetService(name string) (interface{}, error) {
94
58x
    sc.mu.RLock()
95
58x
    defer sc.mu.RUnlock()
96
58x

97
58x
    service, exists := sc.services[name]
98
58x
    if !exists {
99
3x
        return nil, contextutils.ErrorWithContextf("service %s not found", name)
100
3x
    }
101
55x
    return service, nil
102
}
103

104
// GetServiceAs performs type-safe service retrieval
105
54x
func GetServiceAs[T any](sc *ServiceContainer, name string) (T, error) {
106
54x
    var zero T
107
54x
    service, err := sc.GetService(name)
108
54x
    if err != nil {
109
2x
        return zero, err
110
2x
    }
111

112
52x
    typed, ok := service.(T)
113
52x
    if !ok {
114
1x
        return zero, contextutils.ErrorWithContextf("service %s is not of expected type %T", name, zero)
115
1x
    }
116
51x
    return typed, nil
117
}
118

119
// GetUserService returns the user service
120
19x
func (sc *ServiceContainer) GetUserService() (services.UserServiceInterface, error) {
121
19x
    return GetServiceAs[services.UserServiceInterface](sc, "user")
122
19x
}
123

124
// GetQuestionService returns the question service
125
13x
func (sc *ServiceContainer) GetQuestionService() (services.QuestionServiceInterface, error) {
126
13x
    return GetServiceAs[services.QuestionServiceInterface](sc, "question")
127
13x
}
128

129
// GetLearningService returns the learning service
130
3x
func (sc *ServiceContainer) GetLearningService() (services.LearningServiceInterface, error) {
131
3x
    return GetServiceAs[services.LearningServiceInterface](sc, "learning")
132
3x
}
133

134
// GetAIService returns the AI service
135
2x
func (sc *ServiceContainer) GetAIService() (services.AIServiceInterface, error) {
136
2x
    return GetServiceAs[services.AIServiceInterface](sc, "ai")
137
2x
}
138

139
// GetWorkerService returns the worker service
140
2x
func (sc *ServiceContainer) GetWorkerService() (services.WorkerServiceInterface, error) {
141
2x
    return GetServiceAs[services.WorkerServiceInterface](sc, "worker")
142
2x
}
143

144
// GetDailyQuestionService returns the daily question service
145
2x
func (sc *ServiceContainer) GetDailyQuestionService() (services.DailyQuestionServiceInterface, error) {
146
2x
    return GetServiceAs[services.DailyQuestionServiceInterface](sc, "daily_question")
147
2x
}
148

149
// GetStoryService returns the story service
150
1x
func (sc *ServiceContainer) GetStoryService() (services.StoryServiceInterface, error) {
151
1x
    return GetServiceAs[services.StoryServiceInterface](sc, "story")
152
1x
}
153

154
// GetOAuthService returns the OAuth service
155
2x
func (sc *ServiceContainer) GetOAuthService() (*services.OAuthService, error) {
156
2x
    service, err := sc.GetService("oauth")
157
2x
    if err != nil {
158
        return nil, err
159
    }
160
2x
    oauthService, ok := service.(*services.OAuthService)
161
2x
    if !ok {
162
        return nil, contextutils.ErrorWithContextf("oauth service has incorrect type")
163
    }
164
2x
    return oauthService, nil
165
}
166

167
// GetGenerationHintService returns the generation hint service
168
2x
func (sc *ServiceContainer) GetGenerationHintService() (services.GenerationHintServiceInterface, error) {
169
2x
    return GetServiceAs[services.GenerationHintServiceInterface](sc, "generation_hint")
170
2x
}
171

172
// GetConversationService returns the conversation service
173
1x
func (sc *ServiceContainer) GetConversationService() (services.ConversationServiceInterface, error) {
174
1x
    return GetServiceAs[services.ConversationServiceInterface](sc, "conversation")
175
1x
}
176

177
// GetEmailService returns the email service
178
2x
func (sc *ServiceContainer) GetEmailService() (services.EmailServiceInterface, error) {
179
2x
    return GetServiceAs[services.EmailServiceInterface](sc, "email")
180
2x
}
181

182
// GetTranslationService returns the translation service
183
1x
func (sc *ServiceContainer) GetTranslationService() (services.TranslationServiceInterface, error) {
184
1x
    return GetServiceAs[services.TranslationServiceInterface](sc, "translation")
185
1x
}
186

187
// GetSnippetsService returns the snippets service
188
1x
func (sc *ServiceContainer) GetSnippetsService() (services.SnippetsServiceInterface, error) {
189
1x
    return GetServiceAs[services.SnippetsServiceInterface](sc, "snippets")
190
1x
}
191

192
// GetUsageStatsService returns the usage stats service
193
1x
func (sc *ServiceContainer) GetUsageStatsService() (services.UsageStatsServiceInterface, error) {
194
1x
    return GetServiceAs[services.UsageStatsServiceInterface](sc, "usage_stats")
195
1x
}
196

197
// GetWordOfTheDayService returns the word of the day service
198
1x
func (sc *ServiceContainer) GetWordOfTheDayService() (services.WordOfTheDayServiceInterface, error) {
199
1x
    return GetServiceAs[services.WordOfTheDayServiceInterface](sc, "word_of_the_day")
200
1x
}
201

202
// GetAuthAPIKeyService returns the auth API key service
203
1x
func (sc *ServiceContainer) GetAuthAPIKeyService() (services.AuthAPIKeyServiceInterface, error) {
204
1x
    return GetServiceAs[services.AuthAPIKeyServiceInterface](sc, "auth_api_key")
205
1x
}
206

207
// GetDatabase returns the database instance
208
14x
func (sc *ServiceContainer) GetDatabase() *sql.DB {
209
14x
    return sc.db
210
14x
}
211

212
// GetConfig returns the configuration
213
13x
func (sc *ServiceContainer) GetConfig() *config.Config {
214
13x
    return sc.cfg
215
13x
}
216

217
// GetLogger returns the logger
218
13x
func (sc *ServiceContainer) GetLogger() *observability.Logger {
219
13x
    return sc.logger
220
13x
}
221

222
// Shutdown gracefully shuts down all services
223
4x
func (sc *ServiceContainer) Shutdown(ctx context.Context) error {
224
4x
    sc.mu.Lock()
225
4x
    defer sc.mu.Unlock()
226
4x

227
4x
    return sc.cleanup(ctx)
228
4x
}
229

230
// startupServices starts all services that implement the Lifecycle interface
231
8x
func (sc *ServiceContainer) startupServices(ctx context.Context) error {
232
8x
    // Check each service to see if it implements Lifecycle interface
233
8x
    for name, service := range sc.services {
234
120x
        if lifecycleService, ok := service.(interface{ Startup(context.Context) error }); ok {
235
1x
            sc.logger.Info(ctx, "Starting service", map[string]interface{}{"service": name})
236
1x
            if err := lifecycleService.Startup(ctx); err != nil {
237
1x
                return contextutils.WrapErrorf(err, "failed to startup service %s", name)
238
1x
            }
239
            sc.logger.Info(ctx, "Service started successfully", map[string]interface{}{"service": name})
240
        }
241
    }
242
7x
    return nil
243
}
244

245
// cleanup handles shutdown of all services
246
5x
func (sc *ServiceContainer) cleanup(ctx context.Context) error {
247
5x
    var errors []error
248
5x

249
5x
    // Shutdown lifecycle services first (in reverse order)
250
5x
    for name := range sc.services {
251
69x
        if lifecycleService, ok := sc.services[name].(interface{ Shutdown(context.Context) error }); ok {
252
5x
            sc.logger.Info(ctx, "Shutting down service", map[string]interface{}{"service": name})
253
5x
            if err := lifecycleService.Shutdown(ctx); err != nil {
254
1x
                sc.logger.Error(ctx, "Failed to shutdown service", err, map[string]interface{}{"service": name})
255
1x
                errors = append(errors, contextutils.WrapErrorf(err, "service %s shutdown failed", name))
256
1x
            } else {
257
4x
                sc.logger.Info(ctx, "Service shutdown successfully", map[string]interface{}{"service": name})
258
4x
            }
259
        }
260
    }
261

262
    // Shutdown services in reverse order of initialization
263
5x
    for i := len(sc.shutdownFuncs) - 1; i >= 0; i-- {
264
9x
        if err := sc.shutdownFuncs[i](ctx); err != nil {
265
1x
            errors = append(errors, err)
266
1x
        }
267
    }
268

269
5x
    if len(errors) > 0 {
270
1x
        return contextutils.ErrorWithContextf("shutdown errors: %v", errors)
271
1x
    }
272
4x
    return nil
273
}
274

275
// initializeServices sets up all service dependencies
276
7x
func (sc *ServiceContainer) initializeServices(_ context.Context) {
277
7x
    // Core services that don't depend on other services
278
7x
    userService := services.NewUserServiceWithLogger(sc.db, sc.cfg, sc.logger)
279
7x
    sc.services["user"] = userService
280
7x

281
7x
    // Learning service depends on user service
282
7x
    learningService := services.NewLearningServiceWithLogger(sc.db, sc.cfg, sc.logger)
283
7x
    sc.services["learning"] = learningService
284
7x

285
7x
    // Question service depends on learning service
286
7x
    questionService := services.NewQuestionServiceWithLogger(sc.db, learningService, sc.cfg, sc.logger)
287
7x
    sc.services["question"] = questionService
288
7x

289
7x
    // Daily question service depends on question and learning services
290
7x
    dailyQuestionService := services.NewDailyQuestionService(sc.db, sc.logger, questionService, learningService)
291
7x
    sc.services["daily_question"] = dailyQuestionService
292
7x

293
7x
    // Story service
294
7x
    storyService := services.NewStoryService(sc.db, sc.cfg, sc.logger)
295
7x
    sc.services["story"] = storyService
296
7x

297
7x
    // Worker service
298
7x
    workerService := services.NewWorkerServiceWithLogger(sc.db, sc.logger)
299
7x
    sc.services["worker"] = workerService
300
7x

301
7x
    // Generation hint service
302
7x
    generationHintService := services.NewGenerationHintService(sc.db, sc.logger)
303
7x
    sc.services["generation_hint"] = generationHintService
304
7x

305
7x
    // OAuth service
306
7x
    oauthService := services.NewOAuthServiceWithLogger(sc.cfg, sc.logger)
307
7x
    sc.services["oauth"] = oauthService
308
7x

309
7x
    // Conversation service
310
7x
    conversationService := services.NewConversationService(sc.db)
311
7x
    sc.services["conversation"] = conversationService
312
7x

313
7x
    // Email service (use concrete implementation with DB to satisfy EmailServiceInterface)
314
7x
    emailService := services.NewEmailServiceWithDB(sc.cfg, sc.logger, sc.db)
315
7x
    sc.services["email"] = emailService
316
7x

317
7x
    // Usage stats service
318
7x
    usageStatsService := services.NewUsageStatsService(sc.cfg, sc.db, sc.logger)
319
7x
    sc.services["usage_stats"] = usageStatsService
320
7x

321
7x
    // AI service (depends on usage stats service)
322
7x
    aiService := services.NewAIService(sc.cfg, sc.logger, usageStatsService)
323
7x
    sc.services["ai"] = aiService
324
7x

325
7x
    // Translation cache repository
326
7x
    translationCacheRepo := services.NewTranslationCacheRepository(sc.db, sc.logger)
327
7x
    sc.services["translation_cache"] = translationCacheRepo
328
7x

329
7x
    // Translation service (depends on usage stats service and translation cache repository)
330
7x
    translationService := services.NewTranslationService(sc.cfg, usageStatsService, translationCacheRepo, sc.logger)
331
7x
    sc.services["translation"] = translationService
332
7x

333
7x
    // Initialize snippets service
334
7x
    snippetsService := services.NewSnippetsService(sc.db, sc.cfg, sc.logger)
335
7x
    sc.services["snippets"] = snippetsService
336
7x

337
7x
    // Initialize word of the day service
338
7x
    wordOfTheDayService := services.NewWordOfTheDayService(sc.db, sc.logger)
339
7x
    sc.services["word_of_the_day"] = wordOfTheDayService
340
7x

341
7x
    // Initialize auth API key service
342
7x
    authAPIKeyService := services.NewAuthAPIKeyService(sc.db, sc.logger)
343
7x
    sc.services["auth_api_key"] = authAPIKeyService
344
7x

345
7x
    // Register shutdown functions
346
7x
    sc.shutdownFuncs = append(sc.shutdownFuncs,
347
7x
        func(_ context.Context) error { return nil }, // placeholder for future service shutdowns
348
    )
349
}
350

351
// EnsureAdminUser creates the admin user if it doesn't exist
352
4x
func (sc *ServiceContainer) EnsureAdminUser(ctx context.Context) error {
353
4x
    userService, err := sc.GetUserService()
354
4x
    if err != nil {
355
1x
        return contextutils.WrapErrorf(err, "failed to get user service")
356
1x
    }
357

358
3x
    return userService.EnsureAdminUserExists(ctx, sc.cfg.Server.AdminUsername, sc.cfg.Server.AdminPassword)
359
}
360


			
quizapp internal handlers
49.8%
Statements
2615/5256
admin_handler.go
35.5%
278/782
ai_fix_utils.go
59.4%
38/64
ai_handler.go
0.4%
1/233
auth_api_key_handler.go
1.1%
1/87
auth_handler.go
73.2%
213/291
authz.go
86.4%
19/22
convert.go
89.1%
278/312
daily_question_handler.go
56.9%
160/281
error_utils.go
64.4%
29/45
feedback_handler.go
55.0%
127/231
pagination.go
100.0%
20/20
quiz_handler.go
53.7%
293/546
route_listing.go
31.4%
11/35
router_factory.go
96.1%
272/283
session.go
61.5%
16/26
settings_handler.go
70.7%
181/256
snippets_handler.go
10.2%
34/332
story_handler.go
47.9%
161/336
test_mocks.go
27.3%
15/55
translation_handler.go
2.4%
1/42
user_admin_handler.go
44.6%
181/406
verb_conjugation_handler.go
67.0%
61/91
word_of_the_day_handler.go
58.7%
61/104
worker_admin_handler.go
43.6%
164/376
quizapp internal handlers worker_admin_handler.go
35.5%
Statements
278/782
1
// Package handlers provides HTTP request handlers for the quiz application API.
2
package handlers
3

4
import (
5
    "context"
6
    "database/sql"
7
    "encoding/json"
8
    "errors"
9
    "html/template"
10
    "math"
11
    "net/http"
12
    "strconv"
13
    "strings"
14
    "time"
15

16
    "quizapp/internal/config"
17
    "quizapp/internal/models"
18
    "quizapp/internal/observability"
19
    "quizapp/internal/services"
20
    contextutils "quizapp/internal/utils"
21

22
    "github.com/gin-gonic/gin"
23
    "go.opentelemetry.io/otel/attribute"
24
)
25

26
// AdminHandler handles administrative HTTP requests and dashboard functionality
27
type AdminHandler struct {
28
    userService     services.UserServiceInterface
29
    questionService services.QuestionServiceInterface
30
    aiService       services.AIServiceInterface
31
    config          *config.Config
32
    templates       *template.Template
33
    learningService services.LearningServiceInterface
34
    workerService   services.WorkerServiceInterface
35
    logger          *observability.Logger
36
    storyService    services.StoryServiceInterface
37
    usageStatsSvc   services.UsageStatsServiceInterface
38
}
39

40
// NewAdminHandlerWithLogger creates a new AdminHandler with the provided services and logger.
41
13x
func NewAdminHandlerWithLogger(userService services.UserServiceInterface, questionService services.QuestionServiceInterface, aiService services.AIServiceInterface, cfg *config.Config, learningService services.LearningServiceInterface, workerService services.WorkerServiceInterface, logger *observability.Logger, usageStatsSvc services.UsageStatsServiceInterface) *AdminHandler {
42
13x
    return &AdminHandler{
43
13x
        userService:     userService,
44
13x
        questionService: questionService,
45
13x
        aiService:       aiService,
46
13x
        config:          cfg,
47
13x
        templates:       nil,
48
13x
        learningService: learningService,
49
13x
        workerService:   workerService,
50
13x
        logger:          logger,
51
13x
        usageStatsSvc:   usageStatsSvc,
52
13x
    }
53
13x
}
54

55
// GetBackendAdminData returns the backend administration data as JSON
56
func (h *AdminHandler) GetBackendAdminData(c *gin.Context) {
57
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "get_backend_admin_data")
58
    defer observability.FinishSpan(span, nil)
59

60
    // Get all users for aggregate statistics
61
    users, err := h.userService.GetAllUsers(ctx)
62
    if err != nil {
63
        span.SetAttributes(attribute.String("error", err.Error()))
64
        HandleAppError(c, contextutils.WrapError(err, "failed to get users"))
65
        return
66
    }
67

68
    // Calculate aggregate user statistics
69
    userStats := calculateUserAggregateStats(ctx, users, h.learningService, h.logger)
70

71
    // Get question statistics
72
    questionStats, err := h.questionService.GetDetailedQuestionStats(ctx)
73
    if err != nil {
74
        h.logger.Warn(ctx, "Failed to get question stats", map[string]interface{}{"error": err.Error()})
75
        questionStats = make(map[string]interface{})
76
    }
77

78
    // Get worker health if available
79
    var workerHealth map[string]interface{}
80
    if h.workerService != nil {
81
        workerHealth, err = h.workerService.GetWorkerHealth(ctx)
82
        if err != nil {
83
            h.logger.Warn(ctx, "Failed to get worker health", map[string]interface{}{"error": err.Error()})
84
            workerHealth = map[string]interface{}{
85
                "error": "Failed to get worker health",
86
            }
87
        }
88
    }
89

90
    // Get AI concurrency stats
91
    aiStatsStruct := h.aiService.GetConcurrencyStats()
92
    aiConcurrencyStats := map[string]interface{}{
93
        "active_requests":   aiStatsStruct.ActiveRequests,
94
        "max_concurrent":    aiStatsStruct.MaxConcurrent,
95
        "queued_requests":   aiStatsStruct.QueuedRequests,
96
        "total_requests":    aiStatsStruct.TotalRequests,
97
        "user_active_count": aiStatsStruct.UserActiveCount,
98
        "max_per_user":      aiStatsStruct.MaxPerUser,
99
    }
100

101
    data := gin.H{
102
        "user_stats":           userStats,
103
        "question_stats":       questionStats,
104
        "worker_health":        workerHealth,
105
        "ai_concurrency_stats": aiConcurrencyStats,
106
        "worker_port":          h.config.Server.WorkerPort,
107
        "worker_base_url":      h.config.Server.WorkerBaseURL,
108
    }
109

110
    c.JSON(http.StatusOK, data)
111
}
112

113
// GetBackendAdminPage renders the backend administration dashboard
114
1x
func (h *AdminHandler) GetBackendAdminPage(c *gin.Context) {
115
1x
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "get_backend_admin_page")
116
1x
    defer observability.FinishSpan(span, nil)
117
1x

118
1x
    // Get all users with progress and question stats
119
1x
    users, err := h.userService.GetAllUsers(ctx)
120
1x
    if err != nil {
121
        span.SetAttributes(attribute.String("error", err.Error()))
122
        HandleAppError(c, contextutils.WrapError(err, "failed to get users"))
123
        return
124
    }
125

126
1x
    type UserWithProgress struct {
127
1x
        User               models.User
128
1x
        Progress           *models.UserProgress
129
1x
        QuestionStats      *services.UserQuestionStats
130
1x
        UserQuestionCounts map[string]interface{}
131
1x
    }
132
1x

133
1x
    var usersWithProgress []UserWithProgress
134
1x
    for _, user := range users {
135
1x
        progress, err := h.learningService.GetUserProgress(ctx, user.ID)
136
1x
        if err != nil {
137
            h.logger.Warn(ctx, "Failed to get progress for user", map[string]interface{}{"user_id": user.ID, "error": err.Error()})
138
            progress = &models.UserProgress{
139
                CurrentLevel:   "A1",
140
                TotalQuestions: 0,
141
                CorrectAnswers: 0,
142
                AccuracyRate:   0,
143
            }
144
        }
145

146
1x
        questionStats, err := h.learningService.GetUserQuestionStats(ctx, user.ID)
147
1x
        if err != nil {
148
            h.logger.Warn(ctx, "Failed to get question stats for user", map[string]interface{}{"user_id": user.ID, "error": err.Error()})
149
            questionStats = &services.UserQuestionStats{
150
                UserID:        user.ID,
151
                TotalAnswered: 0,
152
            }
153
        }
154

155
        // Get per-user question counts by type and level
156
1x
        userQuestionCounts := make(map[string]interface{})
157
1x

158
1x
        // Use the available stats from UserQuestionStats
159
1x
        if questionStats != nil {
160
1x
            userQuestionCounts["total_answered"] = questionStats.TotalAnswered
161
1x
            userQuestionCounts["answered_by_type"] = questionStats.AnsweredByType
162
1x
            userQuestionCounts["answered_by_level"] = questionStats.AnsweredByLevel
163
1x
            userQuestionCounts["accuracy_by_type"] = questionStats.AccuracyByType
164
1x
            userQuestionCounts["accuracy_by_level"] = questionStats.AccuracyByLevel
165
1x
            userQuestionCounts["available_by_type"] = questionStats.AvailableByType
166
1x
            userQuestionCounts["available_by_level"] = questionStats.AvailableByLevel
167
1x
        }
168

169
1x
        usersWithProgress = append(usersWithProgress, UserWithProgress{
170
1x
            User:               user,
171
1x
            Progress:           progress,
172
1x
            QuestionStats:      questionStats,
173
1x
            UserQuestionCounts: userQuestionCounts,
174
1x
        })
175
    }
176

177
    // Get question statistics
178
1x
    questionStats, err := h.questionService.GetDetailedQuestionStats(ctx)
179
1x
    if err != nil {
180
        h.logger.Warn(ctx, "Failed to get question stats", map[string]interface{}{"error": err.Error()})
181
        questionStats = make(map[string]interface{})
182
    }
183

184
    // Get worker health if available
185
1x
    var workerHealth map[string]interface{}
186
1x
    if h.workerService != nil {
187
1x
        workerHealth, err = h.workerService.GetWorkerHealth(ctx)
188
1x
        if err != nil {
189
            h.logger.Warn(ctx, "Failed to get worker health", map[string]interface{}{"error": err.Error()})
190
            workerHealth = map[string]interface{}{
191
                "error": "Failed to get worker health",
192
            }
193
        }
194
    }
195

196
    // Get AI concurrency stats
197
1x
    aiStatsStruct := h.aiService.GetConcurrencyStats()
198
1x
    aiConcurrencyStats := map[string]interface{}{
199
1x
        "active_requests":   aiStatsStruct.ActiveRequests,
200
1x
        "max_concurrent":    aiStatsStruct.MaxConcurrent,
201
1x
        "queued_requests":   aiStatsStruct.QueuedRequests,
202
1x
        "total_requests":    aiStatsStruct.TotalRequests,
203
1x
        "user_active_count": aiStatsStruct.UserActiveCount,
204
1x
        "max_per_user":      aiStatsStruct.MaxPerUser,
205
1x
    }
206
1x

207
1x
    data := gin.H{
208
1x
        "Title":              "Backend Administration",
209
1x
        "Users":              usersWithProgress,
210
1x
        "QuestionStats":      questionStats,
211
1x
        "WorkerHealth":       workerHealth,
212
1x
        "AIConcurrencyStats": aiConcurrencyStats,
213
1x
        "IsBackend":          true,
214
1x
        "WorkerPort":         h.config.Server.WorkerPort,
215
1x
        "CurrentPage":        "backend_admin",
216
1x
        "WorkerBaseURL":      h.config.Server.WorkerBaseURL,
217
1x
    }
218
1x

219
1x
    // Try to render template, fallback to JSON if template fails
220
1x
    if h.templates != nil {
221
        // Add no-cache headers
222
        c.Header("Content-Type", "text/html; charset=utf-8")
223
        c.Header("Cache-Control", "no-cache, no-store, must-revalidate")
224
        c.Header("Pragma", "no-cache")
225
        c.Header("Expires", "0")
226

227
        if err := h.templates.ExecuteTemplate(c.Writer, "backend_admin.html", data); err != nil {
228
            h.logger.Error(ctx, "Template execution failed", err, map[string]interface{}{})
229
            HandleAppError(c, contextutils.WrapError(err, "failed to render template"))
230
            return
231
        }
232
1x
    } else {
233
1x
        c.JSON(http.StatusOK, data)
234
1x
    }
235
}
236

237
// UserData represents user information combined with their progress data
238
type UserData struct {
239
    User     models.User
240
    Progress *models.UserProgress
241
}
242

243
// UserDataWithQuestions represents user information with questions and responses
244
type UserDataWithQuestions struct {
245
    User            models.User
246
    Progress        *models.UserProgress
247
    QuestionStats   *services.UserQuestionStats
248
    TotalQuestions  int
249
    TotalResponses  int
250
    RecentQuestions []string
251
    Questions       []*services.QuestionWithStats // Actual question objects with stats
252
}
253

254
// ReportedQuestionsData represents the structure for reported questions page data
255
type ReportedQuestionsData struct {
256
    Users             []UserDataWithQuestions
257
    ReportedQuestions []*services.ReportedQuestionWithUser
258
}
259

260
// ShowDatazPage - Removed: Use frontend admin interface instead
261

262
// MarkQuestionAsFixed marks a reported question as fixed and puts it back in rotation
263
1x
func (h *AdminHandler) MarkQuestionAsFixed(c *gin.Context) {
264
1x
    questionIDStr := c.Param("id")
265
1x
    questionID, err := strconv.Atoi(questionIDStr)
266
1x
    if err != nil {
267
        HandleAppError(c, contextutils.ErrInvalidFormat)
268
        return
269
    }
270

271
1x
    if err := h.questionService.MarkQuestionAsFixed(c.Request.Context(), questionID); err != nil {
272
        h.logger.Error(c.Request.Context(), "Failed to mark question as fixed", err, map[string]interface{}{"question_id": questionID})
273

274
        // Check if the error is due to question not found
275
        if contextutils.IsError(err, contextutils.ErrRecordNotFound) {
276
            HandleAppError(c, contextutils.ErrQuestionNotFound)
277
            return
278
        }
279

280
        HandleAppError(c, contextutils.WrapError(err, "failed to mark question as fixed"))
281
        return
282
    }
283

284
1x
    c.JSON(http.StatusOK, gin.H{"message": "Question marked as fixed successfully"})
285
}
286

287
// UpdateQuestion updates a question's content, correct answer, and explanation
288
4x
func (h *AdminHandler) UpdateQuestion(c *gin.Context) {
289
4x
    questionIDStr := c.Param("id")
290
4x
    questionID, err := strconv.Atoi(questionIDStr)
291
4x
    if err != nil {
292
        HandleAppError(c, contextutils.ErrInvalidFormat)
293
        return
294
    }
295

296
4x
    var req struct {
297
4x
        Content       map[string]interface{} `json:"content" binding:"required"`
298
4x
        CorrectAnswer int                    `json:"correct_answer" binding:"gte=0,lte=3"`
299
4x
        Explanation   string                 `json:"explanation" binding:"required"`
300
4x
    }
301
4x

302
4x
    if err := c.ShouldBindJSON(&req); err != nil {
303
1x
        HandleAppError(c, contextutils.NewAppErrorWithCause(
304
1x
            contextutils.ErrorCodeInvalidInput,
305
1x
            contextutils.SeverityWarn,
306
1x
            "Invalid request format",
307
1x
            "",
308
1x
            err,
309
1x
        ))
310
1x
        return
311
1x
    }
312

313
    // Sanitize incoming content to avoid nested `content.content` and duplicated fields.
314
3x
    content := req.Content
315
3x
    for {
316
4x
        if inner, ok := content["content"]; ok {
317
1x
            if innerMap, ok2 := inner.(map[string]interface{}); ok2 {
318
1x
                content = innerMap
319
1x
                continue
320
            }
321
        }
322
3x
        break
323
    }
324

325
    // Remove duplicate top-level keys from the content payload if present.
326
    // Defensive cleanup while migrating to strict OpenAPI validation.
327
3x
    delete(content, "correct_answer")
328
3x
    delete(content, "explanation")
329
3x
    delete(content, "change_reason")
330
3x

331
3x
    // Ensure options is not nil (convert null -> empty slice)
332
3x
    if opts, exists := content["options"]; !exists || opts == nil {
333
        content["options"] = []string{}
334
    }
335

336
3x
    if err := h.questionService.UpdateQuestion(c.Request.Context(), questionID, content, req.CorrectAnswer, req.Explanation); err != nil {
337
        h.logger.Error(c.Request.Context(), "Failed to update question", err, map[string]interface{}{"question_id": questionID})
338

339
        // Check if the error is due to question not found
340
        if contextutils.IsError(err, contextutils.ErrRecordNotFound) {
341
            HandleAppError(c, contextutils.ErrQuestionNotFound)
342
            return
343
        }
344

345
        HandleAppError(c, contextutils.WrapError(err, "failed to update question"))
346
        return
347
    }
348

349
    // If requested, mark the question as fixed and clear reports
350
3x
    if strings.ToLower(c.Query("mark_fixed")) == "true" {
351
2x
        ctx := c.Request.Context()
352
2x
        // Mark as fixed (sets status to active)
353
2x
        if err := h.questionService.MarkQuestionAsFixed(ctx, questionID); err != nil {
354
            h.logger.Error(ctx, "Failed to mark question as fixed after update", err, map[string]interface{}{"question_id": questionID})
355
            HandleAppError(c, contextutils.WrapError(err, "failed to mark question as fixed"))
356
            return
357
        }
358

359
        // Clear question reports
360
2x
        db := h.questionService.DB()
361
2x
        if _, err := db.ExecContext(ctx, `DELETE FROM question_reports WHERE question_id = $1`, questionID); err != nil {
362
            h.logger.Warn(ctx, "Failed to clear question reports", map[string]interface{}{"question_id": questionID, "error": err.Error()})
363
        }
364
    }
365

366
3x
    c.JSON(http.StatusOK, gin.H{"success": true, "message": "Question updated successfully"})
367
}
368

369
// FixQuestionWithAI uses AI to suggest fixes for a problematic question
370
3x
func (h *AdminHandler) FixQuestionWithAI(c *gin.Context) {
371
3x
    questionIDStr := c.Param("id")
372
3x
    questionID, err := strconv.Atoi(questionIDStr)
373
3x
    if err != nil {
374
        HandleAppError(c, contextutils.ErrInvalidFormat)
375
        return
376
    }
377

378
    // Get the original question
379
3x
    question, err := h.questionService.GetQuestionByID(c.Request.Context(), questionID)
380
3x
    if err != nil {
381
1x
        h.logger.Error(c.Request.Context(), "Failed to get question", err, map[string]interface{}{"question_id": questionID})
382
1x

383
1x
        // Check if the error is due to question not found
384
1x
        if errors.Is(err, sql.ErrNoRows) {
385
1x
            HandleAppError(c, contextutils.ErrQuestionNotFound)
386
1x
            return
387
1x
        }
388

389
        HandleAppError(c, contextutils.WrapError(err, "failed to get question"))
390
        return
391
    }
392

393
    // Find reporter(s) and choose a configured AI provider/model from the reporting user(s)
394
2x
    ctx := c.Request.Context()
395
2x
    db := h.questionService.DB()
396
2x
    rows, err := db.QueryContext(ctx, `SELECT u.id, u.username, u.ai_provider, u.ai_model, qr.report_reason FROM question_reports qr JOIN users u ON qr.reported_by_user_id = u.id WHERE qr.question_id = $1 ORDER BY qr.created_at ASC`, questionID)
397
2x
    if err != nil {
398
        h.logger.Error(ctx, "Failed to query question reports", err, map[string]interface{}{"question_id": questionID})
399
        HandleAppError(c, contextutils.WrapError(err, "failed to get report details"))
400
        return
401
    }
402
2x
    if err := rows.Err(); err != nil {
403
        h.logger.Warn(ctx, "rows iteration error before defer", map[string]interface{}{"error": err.Error(), "question_id": questionID})
404
    }
405
2x
    defer func() {
406
2x
        if err := rows.Close(); err != nil {
407
            h.logger.Warn(ctx, "Failed to close report rows", map[string]interface{}{"error": err.Error(), "question_id": questionID})
408
        }
409
    }()
410

411
2x
    var reporterID int
412
2x
    var reporterUsername string
413
2x
    var reporterProvider sql.NullString
414
2x
    var reporterModel sql.NullString
415
2x
    var singleReason sql.NullString
416
2x
    foundProvider := false
417
2x

418
2x
    for rows.Next() {
419
        var uid int
420
        var uname string
421
        var prov sql.NullString
422
        var mod sql.NullString
423
        var reason sql.NullString
424
        if err := rows.Scan(&uid, &uname, &prov, &mod, &reason); err != nil {
425
            h.logger.Warn(ctx, "Failed to scan report row", map[string]interface{}{"error": err.Error(), "question_id": questionID})
426
            continue
427
        }
428
        // Prefer the first reporter that has an AI provider+model configured
429
        if prov.Valid && prov.String != "" && mod.Valid && mod.String != "" {
430
            reporterID = uid
431
            reporterUsername = uname
432
            reporterProvider = prov
433
            reporterModel = mod
434
            singleReason = reason
435
            foundProvider = true
436
            break
437
        }
438
        // Keep the first reporter as fallback (no provider)
439
        if reporterID == 0 {
440
            reporterID = uid
441
            reporterUsername = uname
442
            reporterProvider = prov
443
            reporterModel = mod
444
            singleReason = reason
445
        }
446
    }
447

448
2x
    if !foundProvider {
449
2x
        // If no reporting user has AI configured, fall back to admin user's AI settings or global default provider
450
2x
        h.logger.Info(ctx, "No reporting user has AI configured; attempting fallback to admin or global provider", map[string]interface{}{"question_id": questionID})
451
2x

452
2x
        // Try to get current admin user from context/session
453
2x
        var adminUserID int
454
2x
        if uid, err := GetCurrentUserID(c); err == nil {
455
2x
            adminUserID = uid
456
2x
        }
457

458
        // Try admin user's configured provider/model
459
2x
        if adminUserID != 0 {
460
2x
            adminUser, err := h.userService.GetUserByID(ctx, adminUserID)
461
2x
            if err == nil && adminUser != nil && adminUser.AIProvider.Valid && adminUser.AIProvider.String != "" && adminUser.AIModel.Valid && adminUser.AIModel.String != "" {
462
                reporterID = adminUser.ID
463
                reporterUsername = adminUser.Username
464
                reporterProvider = adminUser.AIProvider
465
                reporterModel = adminUser.AIModel
466
                foundProvider = true
467
                h.logger.Info(ctx, "Falling back to admin user's AI provider", map[string]interface{}{"admin_id": adminUserID, "provider": adminUser.AIProvider.String, "model": adminUser.AIModel.String})
468
            }
469
        }
470

471
        // If still not found, try global config first provider
472
2x
        if !foundProvider && h.config != nil && len(h.config.Providers) > 0 {
473
2x
            p := h.config.Providers[0]
474
2x
            if len(p.Models) > 0 {
475
2x
                // Use first provider and model from global config
476
2x
                reporterProvider = sql.NullString{String: p.Code, Valid: true}
477
2x
                reporterModel = sql.NullString{String: p.Models[0].Code, Valid: true}
478
2x
                reporterUsername = "system"
479
2x
                foundProvider = true
480
2x
                h.logger.Info(ctx, "Falling back to global configured AI provider", map[string]interface{}{"provider": p.Code, "model": p.Models[0].Code})
481
2x
            }
482
        }
483

484
2x
        if !foundProvider {
485
            h.logger.Warn(ctx, "No AI provider configured for reporting users and no fallback available", map[string]interface{}{"question_id": questionID})
486
            HandleAppError(c, contextutils.ErrAIConfigInvalid)
487
            return
488
        }
489
    }
490

491
    // Get saved API key for the reporter's configured provider
492
2x
    savedKey, apiKeyID, _ := h.userService.GetUserAPIKeyWithID(ctx, reporterID, reporterProvider.String)
493
2x

494
2x
    userCfg := &models.UserAIConfig{
495
2x
        Provider: reporterProvider.String,
496
2x
        Model:    reporterModel.String,
497
2x
        APIKey:   savedKey,
498
2x
        Username: reporterUsername,
499
2x
    }
500
2x

501
2x
    // Build AI chat request with question details and report reasons
502
2x
    // Use the template manager to render a structured prompt
503
2x
    // Prepare template data
504
2x
    questionContentJSON, _ := question.MarshalContentToJSON()
505
2x
    // Resolve schema for prompt; fail if none
506
2x
    schema, err := services.GetFixSchema(question.Type)
507
2x
    if err != nil {
508
        h.logger.Error(ctx, "No schema available for question type", err, map[string]interface{}{"question_id": questionID, "type": question.Type})
509
        HandleAppError(c, contextutils.ErrAIConfigInvalid)
510
        return
511
    }
512

513
    // Read optional additional_context from POST body JSON
514
2x
    var body struct {
515
2x
        AdditionalContext string `json:"additional_context"`
516
2x
    }
517
2x
    _ = c.BindJSON(&body) // ignore error; body may be empty
518
2x

519
2x
    tmplData := services.AITemplateData{
520
2x
        CurrentQuestionJSON: questionContentJSON,
521
2x
        ExampleContent:      "", // will be filled below if example available
522
2x
        SchemaForPrompt:     schema,
523
2x
        ReportReasons:       []string{},
524
2x
        AdditionalContext:   body.AdditionalContext,
525
2x
    }
526
2x
    if singleReason.Valid {
527
        tmplData.ReportReasons = []string{singleReason.String}
528
    }
529
    // Load example for this question type if available
530
2x
    if ex, err := h.aiService.TemplateManager().LoadExample(string(question.Type)); err == nil {
531
2x
        tmplData.ExampleContent = ex
532
2x
    }
533

534
2x
    prompt, err := h.aiService.TemplateManager().RenderTemplate(services.AIFixPromptTemplate, tmplData)
535
2x
    if err != nil {
536
        h.logger.Error(ctx, "Failed to render AI fix prompt", err, map[string]interface{}{"question_id": questionID})
537
        HandleAppError(c, contextutils.WrapError(err, "failed to build AI prompt"))
538
        return
539
    }
540

541
    // Use schema as grammar for providers that support it
542
2x
    supportsGrammar := h.aiService.SupportsGrammarField(userCfg.Provider)
543
2x
    var grammar string
544
2x
    if supportsGrammar {
545
2x
        grammar, err = services.GetFixSchema(question.Type)
546
2x
        if err != nil {
547
            h.logger.Error(ctx, "No grammar schema available for question type", err, map[string]interface{}{"question_id": questionID, "type": question.Type})
548
            HandleAppError(c, contextutils.ErrAIConfigInvalid)
549
            return
550
        }
551
    } else {
552
        grammar = ""
553
    }
554

555
    // Add user ID and API key ID to context for usage tracking
556
2x
    if reporterID != 0 {
557
        ctx = contextutils.WithUserID(ctx, reporterID)
558
    }
559
2x
    if apiKeyID != nil {
560
        ctx = contextutils.WithAPIKeyID(ctx, *apiKeyID)
561
    }
562

563
    // Call AI service with constructed prompt and grammar
564
2x
    respStr, err := h.aiService.CallWithPrompt(ctx, userCfg, prompt, grammar)
565
2x
    if err != nil {
566
        h.logger.Error(ctx, "AI service call failed", err, map[string]interface{}{"question_id": questionID, "provider": userCfg.Provider})
567
        HandleAppError(c, contextutils.WrapError(err, "AI service error"))
568
        return
569
    }
570

571
    // Attempt to parse AI response as JSON (and try to recover JSON substring if necessary)
572
2x
    var aiResp map[string]interface{}
573
2x
    if err := json.Unmarshal([]byte(respStr), &aiResp); err != nil {
574
        start := strings.Index(respStr, "{")
575
        end := strings.LastIndex(respStr, "}")
576
        if start >= 0 && end > start {
577
            candidate := respStr[start : end+1]
578
            if err2 := json.Unmarshal([]byte(candidate), &aiResp); err2 != nil {
579
                h.logger.Error(ctx, "Failed to parse AI response as JSON", err2, map[string]interface{}{"question_id": questionID})
580
                HandleAppError(c, contextutils.ErrAIResponseInvalid)
581
                return
582
            }
583
        } else {
584
            h.logger.Error(ctx, "AI did not return JSON", nil, map[string]interface{}{"question_id": questionID})
585
            HandleAppError(c, contextutils.ErrAIResponseInvalid)
586
            return
587
        }
588
    }
589

590
    // Start from the original question map so required top-level fields are preserved
591
2x
    originalMap := map[string]interface{}{}
592
2x
    if b, err := json.Marshal(question); err == nil {
593
2x
        _ = json.Unmarshal(b, &originalMap)
594
2x
    }
595

596
    // Use helper to merge and normalize AI suggestion into original map
597
2x
    suggestion := MergeAISuggestion(originalMap, aiResp)
598
2x
    // Attach admin-provided additional context into suggestion metadata so frontend can display it
599
2x
    if body.AdditionalContext != "" {
600
        suggestion["additional_context"] = body.AdditionalContext
601
    }
602

603
    // If query param apply=true present, apply suggestion directly and mark fixed
604
2x
    if strings.ToLower(c.Query("apply")) == "true" {
605
        // Build update payload: use merged content and read answer/explanation from TOP LEVEL
606
        updateContent := suggestion["content"].(map[string]interface{})
607

608
        // Extract correct_answer from top level (support float64 from JSON)
609
        correctAnswer := 0
610
        if ca, ok := suggestion["correct_answer"]; ok {
611
            switch v := ca.(type) {
612
            case float64:
613
                correctAnswer = int(v)
614
            case int:
615
                correctAnswer = v
616
            }
617
        }
618

619
        // Extract explanation from top level
620
        explanation := ""
621
        if ex, ok := suggestion["explanation"].(string); ok {
622
            explanation = ex
623
        }
624

625
        if err := h.questionService.UpdateQuestion(c.Request.Context(), questionID, updateContent, correctAnswer, explanation); err != nil {
626
            h.logger.Error(c.Request.Context(), "Failed to update question with AI suggestion", err, map[string]interface{}{"question_id": questionID})
627
            HandleAppError(c, contextutils.WrapError(err, "failed to apply suggestion"))
628
            return
629
        }
630

631
        if err := h.questionService.MarkQuestionAsFixed(c.Request.Context(), questionID); err != nil {
632
            h.logger.Warn(c.Request.Context(), "Failed to mark question as fixed after applying suggestion", map[string]interface{}{"question_id": questionID, "error": err.Error()})
633
        }
634
        db := h.questionService.DB()
635
        if _, err := db.ExecContext(c.Request.Context(), `DELETE FROM question_reports WHERE question_id = $1`, questionID); err != nil {
636
            h.logger.Warn(c.Request.Context(), "Failed to clear question reports after applying suggestion", map[string]interface{}{"question_id": questionID, "error": err.Error()})
637
        }
638

639
        c.JSON(http.StatusOK, gin.H{"success": true, "message": "Suggestion applied"})
640
        return
641
    }
642

643
    // Return original question and merged AI suggestion for frontend review
644
2x
    c.JSON(http.StatusOK, gin.H{
645
2x
        "original":   question,
646
2x
        "suggestion": suggestion,
647
2x
    })
648
}
649

650
// ServeDatazJS - Removed: Use frontend admin interface instead
651

652
// GetAIConcurrencyStats returns AI service concurrency metrics
653
func (h *AdminHandler) GetAIConcurrencyStats(c *gin.Context) {
654
    // Get stats from the local AI service instance
655
    stats := h.aiService.GetConcurrencyStats()
656
    c.JSON(http.StatusOK, gin.H{
657
        "ai_concurrency": stats,
658
    })
659
}
660

661
// --- Story Explorer (Admin) ---
662

663
// GetStoriesPaginated returns paginated stories with filters
664
4x
func (h *AdminHandler) GetStoriesPaginated(c *gin.Context) {
665
4x
    if h.storyService == nil {
666
        HandleAppError(c, contextutils.ErrInternalError)
667
        return
668
    }
669
4x
    page, pageSize := ParsePagination(c, 1, 20, 100)
670
4x
    f := ParseFilters(c, "search", "language", "status")
671
4x
    search := f["search"]
672
4x
    language := f["language"]
673
4x
    status := f["status"]
674
4x

675
4x
    var userID *uint
676
4x
    if u := c.Query("user_id"); u != "" {
677
1x
        if parsed, err := strconv.Atoi(u); err == nil && parsed > 0 {
678
1x
            tmp := uint(parsed)
679
1x
            userID = &tmp
680
1x
        } else {
681
            HandleAppError(c, contextutils.ErrInvalidFormat)
682
            return
683
        }
684
    }
685

686
4x
    stories, total, err := h.storyService.GetStoriesPaginated(c.Request.Context(), page, pageSize, search, language, status, userID)
687
4x
    if err != nil {
688
        h.logger.Error(c.Request.Context(), "Failed to get stories", err, map[string]interface{}{"page": page, "size": pageSize})
689
        HandleAppError(c, contextutils.WrapError(err, "failed to get stories"))
690
        return
691
    }
692

693
    // Map directly; convert to API struct for consistency
694
4x
    storyMaps := make([]map[string]interface{}, 0, len(stories))
695
4x
    for _, s := range stories {
696
6x
        apiS := convertStoryToAPI(&s)
697
6x
        m := map[string]interface{}{}
698
6x
        if b, err := json.Marshal(apiS); err == nil {
699
6x
            _ = json.Unmarshal(b, &m)
700
6x
        }
701
6x
        storyMaps = append(storyMaps, m)
702
    }
703

704
4x
    c.JSON(http.StatusOK, gin.H{
705
4x
        "stories": storyMaps,
706
4x
        "pagination": gin.H{
707
4x
            "page":        page,
708
4x
            "page_size":   pageSize,
709
4x
            "total":       total,
710
4x
            "total_pages": int(math.Ceil(float64(total) / float64(pageSize))),
711
4x
        },
712
4x
    })
713
}
714

715
// GetStoryAdmin returns a full story with sections by ID
716
2x
func (h *AdminHandler) GetStoryAdmin(c *gin.Context) {
717
2x
    if h.storyService == nil {
718
        HandleAppError(c, contextutils.ErrInternalError)
719
        return
720
    }
721
2x
    idStr := c.Param("id")
722
2x
    id, err := strconv.Atoi(idStr)
723
2x
    if err != nil || id <= 0 {
724
        HandleAppError(c, contextutils.ErrInvalidFormat)
725
        return
726
    }
727
2x
    story, err := h.storyService.GetStoryAdmin(c.Request.Context(), uint(id))
728
2x
    if err != nil {
729
1x
        h.logger.Error(c.Request.Context(), "Failed to get story", err, map[string]interface{}{"story_id": id})
730
1x
        if strings.Contains(err.Error(), "story not found") {
731
1x
            HandleAppError(c, contextutils.ErrRecordNotFound)
732
1x
            return
733
1x
        }
734
        HandleAppError(c, contextutils.WrapError(err, "failed to get story"))
735
        return
736
    }
737
1x
    c.JSON(http.StatusOK, convertStoryWithSectionsToAPI(story))
738
}
739

740
// GetSectionAdmin returns a section with questions by ID
741
2x
func (h *AdminHandler) GetSectionAdmin(c *gin.Context) {
742
2x
    if h.storyService == nil {
743
        HandleAppError(c, contextutils.ErrInternalError)
744
        return
745
    }
746
2x
    idStr := c.Param("id")
747
2x
    id, err := strconv.Atoi(idStr)
748
2x
    if err != nil || id <= 0 {
749
        HandleAppError(c, contextutils.ErrInvalidFormat)
750
        return
751
    }
752
2x
    section, err := h.storyService.GetSectionAdmin(c.Request.Context(), uint(id))
753
2x
    if err != nil {
754
1x
        h.logger.Error(c.Request.Context(), "Failed to get section", err, map[string]interface{}{"section_id": id})
755
1x
        if strings.Contains(err.Error(), "section not found") {
756
1x
            HandleAppError(c, contextutils.ErrRecordNotFound)
757
1x
            return
758
1x
        }
759
        HandleAppError(c, contextutils.WrapError(err, "failed to get section"))
760
        return
761
    }
762
1x
    c.JSON(http.StatusOK, convertStorySectionWithQuestionsToAPI(section))
763
}
764

765
// DeleteStoryAdmin deletes a story by ID (admin only). Only archived or completed stories can be deleted.
766
func (h *AdminHandler) DeleteStoryAdmin(c *gin.Context) {
767
    if h.storyService == nil {
768
        HandleAppError(c, contextutils.ErrInternalError)
769
        return
770
    }
771
    idStr := c.Param("id")
772
    id, err := strconv.Atoi(idStr)
773
    if err != nil || id <= 0 {
774
        HandleAppError(c, contextutils.ErrInvalidFormat)
775
        return
776
    }
777

778
    if err := h.storyService.DeleteStoryAdmin(c.Request.Context(), uint(id)); err != nil {
779
        h.logger.Error(c.Request.Context(), "Failed to delete story (admin)", err, map[string]interface{}{"story_id": id})
780

781
        if strings.Contains(err.Error(), "not found") {
782
            HandleAppError(c, contextutils.ErrRecordNotFound)
783
            return
784
        }
785
        if strings.Contains(err.Error(), "cannot delete active story") {
786
            HandleAppError(c, contextutils.ErrConflict)
787
            return
788
        }
789
        HandleAppError(c, contextutils.WrapError(err, "failed to delete story"))
790
        return
791
    }
792

793
    c.JSON(http.StatusOK, gin.H{"message": "Story deleted successfully"})
794
}
795

796
// ClearUserData removes all user activity data but keeps the users themselves
797
1x
func (h *AdminHandler) ClearUserData(c *gin.Context) {
798
1x
    err := h.userService.ClearUserData(c.Request.Context())
799
1x
    if err != nil {
800
        h.logger.Error(c.Request.Context(), "Failed to clear user data", err, map[string]interface{}{})
801
        HandleAppError(c, contextutils.WrapError(err, "failed to clear user data"))
802
        return
803
    }
804

805
1x
    c.JSON(http.StatusOK, gin.H{"success": true, "message": "User data cleared successfully (users preserved)"})
806
}
807

808
// ClearDatabase completely resets the database to an empty state
809
1x
func (h *AdminHandler) ClearDatabase(c *gin.Context) {
810
1x
    err := h.userService.ResetDatabase(c.Request.Context())
811
1x
    if err != nil {
812
        h.logger.Error(c.Request.Context(), "Failed to clear database", err, map[string]interface{}{})
813
        HandleAppError(c, contextutils.WrapError(err, "failed to clear database"))
814
        return
815
    }
816

817
1x
    c.JSON(http.StatusOK, gin.H{"success": true, "message": "Database cleared successfully"})
818
}
819

820
// GetQuestion returns a single question by ID for editing
821
2x
func (h *AdminHandler) GetQuestion(c *gin.Context) {
822
2x
    questionIDStr := c.Param("id")
823
2x
    questionID, err := strconv.Atoi(questionIDStr)
824
2x
    if err != nil {
825
        HandleAppError(c, contextutils.ErrInvalidFormat)
826
        return
827
    }
828

829
2x
    question, err := h.questionService.GetQuestionByID(c.Request.Context(), questionID)
830
2x
    if err != nil {
831
1x
        h.logger.Error(c.Request.Context(), "Failed to get question", err, map[string]interface{}{"question_id": questionID})
832
1x
        HandleAppError(c, contextutils.ErrQuestionNotFound)
833
1x
        return
834
1x
    }
835

836
1x
    c.JSON(http.StatusOK, question)
837
}
838

839
// GetUsersForQuestion returns the users assigned to a question
840
2x
func (h *AdminHandler) GetUsersForQuestion(c *gin.Context) {
841
2x
    questionIDStr := c.Param("id")
842
2x
    questionID, err := strconv.Atoi(questionIDStr)
843
2x
    if err != nil {
844
        HandleAppError(c, contextutils.ErrInvalidFormat)
845
        return
846
    }
847

848
2x
    users, totalCount, err := h.questionService.GetUsersForQuestion(c.Request.Context(), questionID)
849
2x
    if err != nil {
850
        h.logger.Error(c.Request.Context(), "Failed to get users for question", err, map[string]interface{}{"question_id": questionID})
851
        HandleAppError(c, contextutils.WrapError(err, "failed to get users for question"))
852
        return
853
    }
854

855
2x
    c.JSON(http.StatusOK, gin.H{
856
2x
        "users":       users,
857
2x
        "total_count": totalCount,
858
2x
    })
859
}
860

861
// AssignUsersToQuestion assigns multiple users to a question
862
2x
func (h *AdminHandler) AssignUsersToQuestion(c *gin.Context) {
863
2x
    questionIDStr := c.Param("id")
864
2x
    questionID, err := strconv.Atoi(questionIDStr)
865
2x
    if err != nil {
866
        HandleAppError(c, contextutils.ErrInvalidFormat)
867
        return
868
    }
869

870
2x
    var request struct {
871
2x
        UserIDs []int `json:"user_ids" binding:"required"`
872
2x
    }
873
2x

874
2x
    if err := c.ShouldBindJSON(&request); err != nil {
875
        HandleAppError(c, contextutils.ErrInvalidInput)
876
        return
877
    }
878

879
    // Validate non-empty user list
880
2x
    if len(request.UserIDs) == 0 {
881
        HandleAppError(c, contextutils.ErrInvalidInput)
882
        return
883
    }
884

885
    // Check if the question exists first
886
2x
    _, err = h.questionService.GetQuestionByID(c.Request.Context(), questionID)
887
2x
    if err != nil {
888
1x
        h.logger.Error(c.Request.Context(), "Failed to get question", err, map[string]interface{}{"question_id": questionID})
889
1x

890
1x
        // Check if the error is due to question not found
891
1x
        if errors.Is(err, sql.ErrNoRows) {
892
1x
            HandleAppError(c, contextutils.ErrQuestionNotFound)
893
1x
            return
894
1x
        }
895

896
        HandleAppError(c, contextutils.WrapError(err, "failed to get question"))
897
        return
898
    }
899

900
1x
    err = h.questionService.AssignUsersToQuestion(c.Request.Context(), questionID, request.UserIDs)
901
1x
    if err != nil {
902
        h.logger.Error(c.Request.Context(), "Failed to assign users to question", err, map[string]interface{}{
903
            "question_id": questionID,
904
            "user_ids":    request.UserIDs,
905
        })
906
        HandleAppError(c, contextutils.WrapError(err, "failed to assign users to question"))
907
        return
908
    }
909

910
1x
    c.JSON(http.StatusOK, gin.H{"message": "Users assigned to question successfully"})
911
}
912

913
// UnassignUsersFromQuestion removes multiple users from a question
914
2x
func (h *AdminHandler) UnassignUsersFromQuestion(c *gin.Context) {
915
2x
    questionIDStr := c.Param("id")
916
2x
    questionID, err := strconv.Atoi(questionIDStr)
917
2x
    if err != nil {
918
        HandleAppError(c, contextutils.ErrInvalidFormat)
919
        return
920
    }
921

922
2x
    var request struct {
923
2x
        UserIDs []int `json:"user_ids" binding:"required"`
924
2x
    }
925
2x

926
2x
    if err := c.ShouldBindJSON(&request); err != nil {
927
        HandleAppError(c, contextutils.NewAppErrorWithCause(contextutils.ErrorCodeInvalidInput, contextutils.SeverityWarn, "Invalid request body", "", err))
928
        return
929
    }
930

931
    // Validate non-empty user list
932
2x
    if len(request.UserIDs) == 0 {
933
        HandleAppError(c, contextutils.ErrInvalidInput)
934
        return
935
    }
936

937
    // Check if the question exists first
938
2x
    _, err = h.questionService.GetQuestionByID(c.Request.Context(), questionID)
939
2x
    if err != nil {
940
1x
        h.logger.Error(c.Request.Context(), "Failed to get question", err, map[string]interface{}{"question_id": questionID})
941
1x

942
1x
        // Check if the error is due to question not found
943
1x
        if errors.Is(err, sql.ErrNoRows) {
944
1x
            HandleAppError(c, contextutils.ErrQuestionNotFound)
945
1x
            return
946
1x
        }
947

948
        HandleAppError(c, contextutils.WrapError(err, "failed to get question"))
949
        return
950
    }
951

952
1x
    err = h.questionService.UnassignUsersFromQuestion(c.Request.Context(), questionID, request.UserIDs)
953
1x
    if err != nil {
954
        h.logger.Error(c.Request.Context(), "Failed to unassign users from question", err, map[string]interface{}{
955
            "question_id": questionID,
956
            "user_ids":    request.UserIDs,
957
        })
958
        HandleAppError(c, contextutils.WrapError(err, "failed to unassign users from question"))
959
        return
960
    }
961

962
1x
    c.JSON(http.StatusOK, gin.H{"message": "Users unassigned from question successfully"})
963
}
964

965
// DeleteQuestion deletes a question by ID
966
1x
func (h *AdminHandler) DeleteQuestion(c *gin.Context) {
967
1x
    questionIDStr := c.Param("id")
968
1x
    questionID, err := strconv.Atoi(questionIDStr)
969
1x
    if err != nil {
970
        HandleAppError(c, contextutils.ErrInvalidFormat)
971
        return
972
    }
973

974
1x
    err = h.questionService.DeleteQuestion(c.Request.Context(), questionID)
975
1x
    if err != nil {
976
        h.logger.Error(c.Request.Context(), "Failed to delete question", err, map[string]interface{}{"question_id": questionID})
977

978
        // Check if the error is due to question not found
979
        if contextutils.IsError(err, contextutils.ErrRecordNotFound) {
980
            HandleAppError(c, contextutils.ErrQuestionNotFound)
981
            return
982
        }
983

984
        HandleAppError(c, contextutils.WrapError(err, "failed to delete question"))
985
        return
986
    }
987

988
1x
    c.JSON(http.StatusOK, gin.H{"message": "Question deleted successfully"})
989
}
990

991
// GetQuestionsPaginated returns paginated questions with response statistics
992
1x
func (h *AdminHandler) GetQuestionsPaginated(c *gin.Context) {
993
1x
    userIDStr := c.Query("user_id")
994
1x
    if userIDStr == "" {
995
        HandleAppError(c, contextutils.ErrMissingRequired)
996
        return
997
    }
998

999
1x
    userID, err := strconv.Atoi(userIDStr)
1000
1x
    if err != nil {
1001
        HandleAppError(c, contextutils.ErrInvalidFormat)
1002
        return
1003
    }
1004

1005
    // Parse pagination and filters
1006
1x
    page, pageSize := ParsePagination(c, 1, 10, 100)
1007
1x
    filters := ParseFilters(c, "search", "type", "status")
1008
1x
    search := filters["search"]
1009
1x
    typeFilter := filters["type"]
1010
1x
    statusFilter := filters["status"]
1011
1x

1012
1x
    // Get questions with filters
1013
1x
    questions, total, err := h.questionService.GetQuestionsPaginated(
1014
1x
        c.Request.Context(),
1015
1x
        userID,
1016
1x
        page,
1017
1x
        pageSize,
1018
1x
        search,
1019
1x
        typeFilter,
1020
1x
        statusFilter,
1021
1x
    )
1022
1x
    if err != nil {
1023
        h.logger.Error(c.Request.Context(), "Failed to get paginated questions", err, map[string]interface{}{
1024
            "user_id": userID,
1025
            "page":    page,
1026
            "size":    pageSize,
1027
        })
1028
        HandleAppError(c, contextutils.WrapError(err, "failed to get questions"))
1029
        return
1030
    }
1031

1032
1x
    c.JSON(http.StatusOK, gin.H{
1033
1x
        "questions": func() []map[string]interface{} {
1034
1x
            out := make([]map[string]interface{}, 0, len(questions))
1035
1x
            for _, q := range questions {
1036
                out = append(out, convertQuestionWithStatsToAPIMap(q))
1037
            }
1038
1x
            return out
1039
        }(),
1040
        "pagination": gin.H{
1041
            "page":        page,
1042
            "page_size":   pageSize,
1043
            "total":       total,
1044
            "total_pages": int(math.Ceil(float64(total) / float64(pageSize))),
1045
        },
1046
    })
1047
}
1048

1049
// GetAllQuestions returns all questions with pagination and filtering
1050
func (h *AdminHandler) GetAllQuestions(c *gin.Context) {
1051
    // Parse pagination and filters
1052
    page, pageSize := ParsePagination(c, 1, 20, 100)
1053
    f := ParseFilters(c, "search", "type", "status", "language", "level")
1054
    search := f["search"]
1055
    typeFilter := f["type"]
1056
    statusFilter := f["status"]
1057
    languageFilter := f["language"]
1058
    levelFilter := f["level"]
1059
    userIDStr := c.Query("user_id")
1060

1061
    // Parse user_id if provided
1062
    var userID *int
1063
    if userIDStr != "" {
1064
        uid, err := strconv.Atoi(userIDStr)
1065
        if err != nil {
1066
            HandleAppError(c, contextutils.ErrInvalidFormat)
1067
            return
1068
        }
1069
        userID = &uid
1070
    }
1071

1072
    // Get questions with filters
1073
    questions, total, err := h.questionService.GetAllQuestionsPaginated(
1074
        c.Request.Context(),
1075
        page,
1076
        pageSize,
1077
        search,
1078
        typeFilter,
1079
        statusFilter,
1080
        languageFilter,
1081
        levelFilter,
1082
        userID,
1083
    )
1084
    if err != nil {
1085
        h.logger.Error(c.Request.Context(), "Failed to get all questions", err, map[string]interface{}{
1086
            "page":   page,
1087
            "size":   pageSize,
1088
            "search": search,
1089
        })
1090
        HandleAppError(c, contextutils.WrapError(err, "failed to get questions"))
1091
        return
1092
    }
1093

1094
    // Get stats
1095
    stats, err := h.questionService.GetQuestionStats(c.Request.Context())
1096
    if err != nil {
1097
        h.logger.Warn(c.Request.Context(), "Failed to get question stats", map[string]interface{}{"error": err.Error()})
1098
        stats = map[string]interface{}{}
1099
    }
1100

1101
    c.JSON(http.StatusOK, gin.H{
1102
        "questions": func() []map[string]interface{} {
1103
            out := make([]map[string]interface{}, 0, len(questions))
1104
            for _, q := range questions {
1105
                out = append(out, convertQuestionWithStatsToAPIMap(q))
1106
            }
1107
            return out
1108
        }(),
1109
        "pagination": gin.H{
1110
            "page":        page,
1111
            "page_size":   pageSize,
1112
            "total":       total,
1113
            "total_pages": int(math.Ceil(float64(total) / float64(pageSize))),
1114
        },
1115
        "stats": stats,
1116
    })
1117
}
1118

1119
// GetReportedQuestionsPaginated returns reported questions with pagination and filtering
1120
1x
func (h *AdminHandler) GetReportedQuestionsPaginated(c *gin.Context) {
1121
1x
    // Parse pagination and filters
1122
1x
    page, pageSize := ParsePagination(c, 1, 20, 100)
1123
1x
    f := ParseFilters(c, "search", "type", "language", "level")
1124
1x
    search := f["search"]
1125
1x
    typeFilter := f["type"]
1126
1x
    languageFilter := f["language"]
1127
1x
    levelFilter := f["level"]
1128
1x

1129
1x
    // Get reported questions with filters
1130
1x
    questions, total, err := h.questionService.GetReportedQuestionsPaginated(
1131
1x
        c.Request.Context(),
1132
1x
        page,
1133
1x
        pageSize,
1134
1x
        search,
1135
1x
        typeFilter,
1136
1x
        languageFilter,
1137
1x
        levelFilter,
1138
1x
    )
1139
1x
    if err != nil {
1140
        h.logger.Error(c.Request.Context(), "Failed to get reported questions", err, map[string]interface{}{
1141
            "page":   page,
1142
            "size":   pageSize,
1143
            "search": search,
1144
        })
1145
        HandleAppError(c, contextutils.WrapError(err, "failed to get reported questions"))
1146
        return
1147
    }
1148

1149
    // Get reported questions stats
1150
1x
    stats, err := h.questionService.GetReportedQuestionsStats(c.Request.Context())
1151
1x
    if err != nil {
1152
        h.logger.Warn(c.Request.Context(), "Failed to get reported questions stats", map[string]interface{}{"error": err.Error()})
1153
        stats = map[string]interface{}{}
1154
    }
1155

1156
1x
    c.JSON(http.StatusOK, gin.H{
1157
1x
        "questions": func() []map[string]interface{} {
1158
1x
            out := make([]map[string]interface{}, 0, len(questions))
1159
1x
            for _, q := range questions {
1160
1x
                out = append(out, convertQuestionWithStatsToAPIMap(q))
1161
1x
            }
1162
1x
            return out
1163
        }(),
1164
        "pagination": gin.H{
1165
            "page":        page,
1166
            "page_size":   pageSize,
1167
            "total":       total,
1168
            "total_pages": int(math.Ceil(float64(total) / float64(pageSize))),
1169
        },
1170
        "stats": stats,
1171
    })
1172
}
1173

1174
// ClearUserDataForUser removes all user activity data for a specific user but keeps the user record
1175
1x
func (h *AdminHandler) ClearUserDataForUser(c *gin.Context) {
1176
1x
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "clear_user_data_for_user")
1177
1x
    defer observability.FinishSpan(span, nil)
1178
1x
    userIDStr := c.Param("id")
1179
1x
    userID, err := strconv.Atoi(userIDStr)
1180
1x
    if err != nil {
1181
        HandleAppError(c, contextutils.ErrInvalidFormat)
1182
        return
1183
    }
1184

1185
    // Check if user exists before attempting to clear data
1186
1x
    user, err := h.userService.GetUserByID(ctx, userID)
1187
1x
    if err != nil {
1188
        h.logger.Error(ctx, "Failed to get user for clear data operation", err, map[string]interface{}{"user_id": userID})
1189
        HandleAppError(c, contextutils.WrapError(err, "failed to get user"))
1190
        return
1191
    }
1192
1x
    if user == nil {
1193
        HandleAppError(c, contextutils.ErrRecordNotFound)
1194
        return
1195
    }
1196

1197
1x
    err = h.userService.ClearUserDataForUser(ctx, userID)
1198
1x
    if err != nil {
1199
        h.logger.Error(ctx, "Failed to clear user data for user", err, map[string]interface{}{"user_id": userID})
1200
        HandleAppError(c, contextutils.WrapError(err, "failed to clear user data for user"))
1201
        return
1202
    }
1203
1x
    c.JSON(http.StatusOK, gin.H{"success": true, "message": "User data cleared successfully (user preserved)"})
1204
}
1205

1206
// GetConfigz returns the merged config as pretty-printed JSON
1207
2x
func (h *AdminHandler) GetConfigz(c *gin.Context) {
1208
2x
    _, span := observability.TraceHandlerFunction(c.Request.Context(), "get_configz")
1209
2x
    defer observability.FinishSpan(span, nil)
1210
2x
    c.IndentedJSON(http.StatusOK, h.config)
1211
2x
}
1212

1213
// GetRoles returns all available roles in the system
1214
func (h *AdminHandler) GetRoles(c *gin.Context) {
1215
    _, span := observability.TraceHandlerFunction(c.Request.Context(), "get_roles")
1216
    defer observability.FinishSpan(span, nil)
1217

1218
    // For now, return hardcoded roles since we don't have a role service
1219
    // In a real implementation, you'd query the database
1220
    roles := []models.Role{
1221
        {ID: 1, Name: "user", Description: "Normal site access", CreatedAt: time.Now(), UpdatedAt: time.Now()},
1222
        {ID: 2, Name: "admin", Description: "Administrative access to all features", CreatedAt: time.Now(), UpdatedAt: time.Now()},
1223
    }
1224

1225
    c.JSON(http.StatusOK, gin.H{"roles": roles})
1226
}
1227

1228
// GetUserRoles returns all roles for a specific user
1229
func (h *AdminHandler) GetUserRoles(c *gin.Context) {
1230
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "get_user_roles")
1231
    defer observability.FinishSpan(span, nil)
1232

1233
    userIDStr := c.Param("id")
1234
    userID, err := strconv.Atoi(userIDStr)
1235
    if err != nil {
1236
        HandleAppError(c, contextutils.ErrInvalidFormat)
1237
        return
1238
    }
1239

1240
    // Check if user exists before getting roles
1241
    user, err := h.userService.GetUserByID(ctx, userID)
1242
    if err != nil {
1243
        h.logger.Error(ctx, "Failed to get user for roles operation", err, map[string]interface{}{"user_id": userID})
1244
        HandleAppError(c, contextutils.WrapError(err, "failed to get user"))
1245
        return
1246
    }
1247
    if user == nil {
1248
        HandleAppError(c, contextutils.ErrRecordNotFound)
1249
        return
1250
    }
1251

1252
    roles, err := h.userService.GetUserRoles(ctx, userID)
1253
    if err != nil {
1254
        h.logger.Error(ctx, "Failed to get user roles", err, map[string]interface{}{"user_id": userID})
1255
        HandleAppError(c, contextutils.WrapError(err, "failed to get user roles"))
1256
        return
1257
    }
1258

1259
    c.JSON(http.StatusOK, gin.H{"roles": roles})
1260
}
1261

1262
// AssignRole assigns a role to a user
1263
func (h *AdminHandler) AssignRole(c *gin.Context) {
1264
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "assign_role")
1265
    defer observability.FinishSpan(span, nil)
1266

1267
    userIDStr := c.Param("id")
1268
    userID, err := strconv.Atoi(userIDStr)
1269
    if err != nil {
1270
        HandleAppError(c, contextutils.ErrInvalidFormat)
1271
        return
1272
    }
1273

1274
    // Check if user exists before assigning role
1275
    user, err := h.userService.GetUserByID(ctx, userID)
1276
    if err != nil {
1277
        h.logger.Error(ctx, "Failed to get user for role assignment", err, map[string]interface{}{"user_id": userID})
1278
        HandleAppError(c, contextutils.WrapError(err, "failed to get user"))
1279
        return
1280
    }
1281
    if user == nil {
1282
        HandleAppError(c, contextutils.ErrRecordNotFound)
1283
        return
1284
    }
1285

1286
    var req struct {
1287
        RoleID int `json:"role_id" binding:"required"`
1288
    }
1289
    if err := c.ShouldBindJSON(&req); err != nil {
1290
        HandleAppError(c, contextutils.NewAppErrorWithCause(contextutils.ErrorCodeInvalidInput, contextutils.SeverityWarn, "Invalid request body", "", err))
1291
        return
1292
    }
1293

1294
    // Ensure the requester is allowed (self or admin). Route is admin-only, but keep explicit check.
1295
    currentUserID, err := GetCurrentUserID(c)
1296
    if err == nil {
1297
        if err := RequireSelfOrAdmin(ctx, h.userService, currentUserID, userID); err != nil {
1298
            if errors.Is(err, ErrForbidden) {
1299
                HandleAppError(c, contextutils.ErrForbidden)
1300
                return
1301
            }
1302
            h.logger.Error(ctx, "Failed to check authorization", err, map[string]interface{}{"user_id": currentUserID})
1303
            HandleAppError(c, contextutils.WrapError(err, "failed to check authorization"))
1304
            return
1305
        }
1306
    }
1307

1308
    err = h.userService.AssignRole(ctx, userID, req.RoleID)
1309
    if err != nil {
1310
        h.logger.Error(ctx, "Failed to assign role to user", err, map[string]interface{}{"user_id": userID, "role_id": req.RoleID})
1311
        HandleAppError(c, contextutils.WrapError(err, "failed to assign role"))
1312
        return
1313
    }
1314

1315
    c.JSON(http.StatusOK, gin.H{"message": "Role assigned successfully"})
1316
}
1317

1318
// RemoveRole removes a role from a user
1319
func (h *AdminHandler) RemoveRole(c *gin.Context) {
1320
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "remove_role")
1321
    defer observability.FinishSpan(span, nil)
1322

1323
    userIDStr := c.Param("id")
1324
    userID, err := strconv.Atoi(userIDStr)
1325
    if err != nil {
1326
        HandleAppError(c, contextutils.ErrInvalidFormat)
1327
        return
1328
    }
1329

1330
    // Check if user exists before removing role
1331
    user, err := h.userService.GetUserByID(ctx, userID)
1332
    if err != nil {
1333
        h.logger.Error(ctx, "Failed to get user for role removal", err, map[string]interface{}{"user_id": userID})
1334
        HandleAppError(c, contextutils.WrapError(err, "failed to get user"))
1335
        return
1336
    }
1337
    if user == nil {
1338
        HandleAppError(c, contextutils.ErrRecordNotFound)
1339
        return
1340
    }
1341

1342
    roleIDStr := c.Param("roleId")
1343
    roleID, err := strconv.Atoi(roleIDStr)
1344
    if err != nil {
1345
        HandleAppError(c, contextutils.ErrInvalidFormat)
1346
        return
1347
    }
1348

1349
    // Ensure the requester is allowed (self or admin). Route is admin-only, but keep explicit check.
1350
    currentUserID, err := GetCurrentUserID(c)
1351
    if err == nil {
1352
        if err := RequireSelfOrAdmin(ctx, h.userService, currentUserID, userID); err != nil {
1353
            if errors.Is(err, ErrForbidden) {
1354
                HandleAppError(c, contextutils.ErrForbidden)
1355
                return
1356
            }
1357
            h.logger.Error(ctx, "Failed to check authorization", err, map[string]interface{}{"user_id": currentUserID})
1358
            HandleAppError(c, contextutils.WrapError(err, "failed to check authorization"))
1359
            return
1360
        }
1361
    }
1362

1363
    err = h.userService.RemoveRole(ctx, userID, roleID)
1364
    if err != nil {
1365
        h.logger.Error(ctx, "Failed to remove role", err, map[string]interface{}{"user_id": userID, "role_id": roleID})
1366

1367
        // Check if it's a "user does not have role" error
1368
        if strings.Contains(err.Error(), "does not have role") {
1369
            HandleAppError(c, contextutils.ErrRecordNotFound)
1370
            return
1371
        }
1372

1373
        // Check if it's a "user not found" or "role not found" error
1374
        if contextutils.IsError(err, contextutils.ErrRecordNotFound) {
1375
            HandleAppError(c, contextutils.ErrRecordNotFound)
1376
            return
1377
        }
1378

1379
        HandleAppError(c, contextutils.WrapError(err, "failed to remove role"))
1380
        return
1381
    }
1382

1383
    c.JSON(http.StatusOK, gin.H{"message": "Role removed successfully"})
1384
}
1385

1386
// GetUsageStats returns usage statistics for the admin interface
1387
func (h *AdminHandler) GetUsageStats(c *gin.Context) {
1388
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "get_usage_stats")
1389
    defer observability.FinishSpan(span, nil)
1390

1391
    if h.usageStatsSvc == nil {
1392
        HandleAppError(c, contextutils.ErrInternalError)
1393
        return
1394
    }
1395

1396
    // Get all usage stats
1397
    stats, err := h.usageStatsSvc.GetAllUsageStats(ctx)
1398
    if err != nil {
1399
        h.logger.Error(ctx, "Failed to get usage stats", err, map[string]interface{}{})
1400
        HandleAppError(c, contextutils.WrapError(err, "failed to get usage stats"))
1401
        return
1402
    }
1403

1404
    // Group stats by service and month for easier frontend consumption
1405
    serviceStats := make(map[string]map[string]map[string]interface{})
1406
    monthlyTotals := make(map[string]map[string]interface{})
1407

1408
    // Track cache statistics across all services
1409
    var totalCacheHitsRequests, totalCacheHitsCharacters, totalCacheMissesRequests int
1410

1411
    for _, stat := range stats {
1412
        serviceName := stat.ServiceName
1413
        usageType := stat.UsageType
1414
        month := stat.UsageMonth.Format("2006-01")
1415

1416
        if serviceStats[serviceName] == nil {
1417
            serviceStats[serviceName] = make(map[string]map[string]interface{})
1418
        }
1419
        if serviceStats[serviceName][month] == nil {
1420
            serviceStats[serviceName][month] = make(map[string]interface{})
1421
        }
1422

1423
        serviceStats[serviceName][month][usageType] = map[string]interface{}{
1424
            "characters_used": stat.CharactersUsed,
1425
            "requests_made":   stat.RequestsMade,
1426
            "quota":           h.usageStatsSvc.GetMonthlyQuota(serviceName),
1427
        }
1428

1429
        // Accumulate cache statistics
1430
        switch usageType {
1431
        case "translation_cache_hit":
1432
            totalCacheHitsRequests += stat.RequestsMade
1433
            totalCacheHitsCharacters += stat.CharactersUsed
1434
        case "translation_cache_miss":
1435
            totalCacheMissesRequests += stat.RequestsMade
1436
        }
1437

1438
        // Accumulate monthly totals (only for actual translations, not cache)
1439
        if usageType == "translation" {
1440
            if monthlyTotals[month] == nil {
1441
                monthlyTotals[month] = make(map[string]interface{})
1442
            }
1443
            if monthlyTotals[month][serviceName] == nil {
1444
                monthlyTotals[month][serviceName] = map[string]interface{}{
1445
                    "total_characters": 0,
1446
                    "total_requests":   0,
1447
                }
1448
            }
1449

1450
            totalChars := monthlyTotals[month][serviceName].(map[string]interface{})["total_characters"].(int) + stat.CharactersUsed
1451
            totalReqs := monthlyTotals[month][serviceName].(map[string]interface{})["total_requests"].(int) + stat.RequestsMade
1452

1453
            monthlyTotals[month][serviceName].(map[string]interface{})["total_characters"] = totalChars
1454
            monthlyTotals[month][serviceName].(map[string]interface{})["total_requests"] = totalReqs
1455
        }
1456
    }
1457

1458
    // Calculate cache hit rate
1459
    totalCacheRequests := totalCacheHitsRequests + totalCacheMissesRequests
1460
    var cacheHitRate float64
1461
    if totalCacheRequests > 0 {
1462
        cacheHitRate = (float64(totalCacheHitsRequests) / float64(totalCacheRequests)) * 100
1463
    }
1464

1465
    c.JSON(http.StatusOK, gin.H{
1466
        "usage_stats":    serviceStats,
1467
        "monthly_totals": monthlyTotals,
1468
        "services":       []string{"google"}, // Currently only Google Translate
1469
        "cache_stats": gin.H{
1470
            "total_cache_hits_requests":   totalCacheHitsRequests,
1471
            "total_cache_hits_characters": totalCacheHitsCharacters,
1472
            "total_cache_misses_requests": totalCacheMissesRequests,
1473
            "cache_hit_rate":              cacheHitRate,
1474
        },
1475
    })
1476
}
1477

1478
// GetUsageStatsByService returns usage statistics for a specific service
1479
func (h *AdminHandler) GetUsageStatsByService(c *gin.Context) {
1480
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "get_usage_stats_by_service")
1481
    defer observability.FinishSpan(span, nil)
1482

1483
    serviceName := c.Param("service")
1484
    if serviceName == "" {
1485
        HandleAppError(c, contextutils.ErrInvalidFormat)
1486
        return
1487
    }
1488

1489
    // Validate service name against configured translation providers
1490
    if !h.config.Translation.Enabled {
1491
        HandleAppError(c, contextutils.ErrInvalidFormat)
1492
        return
1493
    }
1494

1495
    isValidService := false
1496
    for providerCode := range h.config.Translation.Providers {
1497
        if providerCode == serviceName {
1498
            isValidService = true
1499
            break
1500
        }
1501
    }
1502

1503
    if !isValidService {
1504
        HandleAppError(c, contextutils.ErrInvalidFormat)
1505
        return
1506
    }
1507

1508
    if h.usageStatsSvc == nil {
1509
        HandleAppError(c, contextutils.ErrInternalError)
1510
        return
1511
    }
1512

1513
    stats, err := h.usageStatsSvc.GetUsageStatsByService(ctx, serviceName)
1514
    if err != nil {
1515
        h.logger.Error(ctx, "Failed to get usage stats by service", err, map[string]interface{}{"service": serviceName})
1516
        HandleAppError(c, contextutils.WrapError(err, "failed to get usage stats"))
1517
        return
1518
    }
1519

1520
    // Format for frontend consumption
1521
    monthlyData := make([]map[string]interface{}, 0)
1522
    for _, stat := range stats {
1523
        // Only show quota for actual translation usage, not for cache hits/misses
1524
        var quota interface{}
1525
        if stat.UsageType == "translation" {
1526
            quota = h.usageStatsSvc.GetMonthlyQuota(serviceName)
1527
        } else {
1528
            quota = nil
1529
        }
1530

1531
        monthlyData = append(monthlyData, map[string]interface{}{
1532
            "month":           stat.UsageMonth.Format("2006-01"),
1533
            "usage_type":      stat.UsageType,
1534
            "characters_used": stat.CharactersUsed,
1535
            "requests_made":   stat.RequestsMade,
1536
            "quota":           quota,
1537
        })
1538
    }
1539

1540
    c.JSON(http.StatusOK, gin.H{
1541
        "service": serviceName,
1542
        "data":    monthlyData,
1543
    })
1544
}
1545

1546
// calculateUserAggregateStats calculates aggregate statistics for all users
1547
func calculateUserAggregateStats(ctx context.Context, users []models.User, learningService services.LearningServiceInterface, logger *observability.Logger) map[string]interface{} {
1548
    stats := map[string]interface{}{
1549
        "total_users":              len(users),
1550
        "by_language":              make(map[string]int),
1551
        "by_level":                 make(map[string]int),
1552
        "by_ai_provider":           make(map[string]int),
1553
        "by_ai_model":              make(map[string]int),
1554
        "ai_enabled":               0,
1555
        "ai_disabled":              0,
1556
        "active_users":             0,
1557
        "inactive_users":           0,
1558
        "total_questions_answered": 0,
1559
        "total_correct_answers":    0,
1560
        "average_accuracy":         0.0,
1561
    }
1562

1563
    activeThreshold := time.Now().AddDate(0, 0, -7)
1564

1565
    for _, user := range users {
1566
        lang := "unknown"
1567
        if user.PreferredLanguage.Valid {
1568
            lang = user.PreferredLanguage.String
1569
        }
1570
        stats["by_language"].(map[string]int)[lang]++
1571

1572
        level := "unknown"
1573
        if user.CurrentLevel.Valid {
1574
            level = user.CurrentLevel.String
1575
        }
1576
        stats["by_level"].(map[string]int)[level]++
1577

1578
        provider := "none"
1579
        if user.AIProvider.Valid {
1580
            provider = user.AIProvider.String
1581
        }
1582
        stats["by_ai_provider"].(map[string]int)[provider]++
1583

1584
        model := "none"
1585
        if user.AIModel.Valid {
1586
            model = user.AIModel.String
1587
        }
1588
        stats["by_ai_model"].(map[string]int)[model]++
1589

1590
        if user.AIEnabled.Valid && user.AIEnabled.Bool {
1591
            aiEnabled := stats["ai_enabled"].(int)
1592
            stats["ai_enabled"] = aiEnabled + 1
1593
        } else {
1594
            aiDisabled := stats["ai_disabled"].(int)
1595
            stats["ai_disabled"] = aiDisabled + 1
1596
        }
1597

1598
        if user.LastActive.Valid {
1599
            lastActive := user.LastActive.Time
1600
            if lastActive.After(activeThreshold) {
1601
                activeUsers := stats["active_users"].(int)
1602
                stats["active_users"] = activeUsers + 1
1603
            } else {
1604
                inactiveUsers := stats["inactive_users"].(int)
1605
                stats["inactive_users"] = inactiveUsers + 1
1606
            }
1607
        } else {
1608
            inactiveUsers := stats["inactive_users"].(int)
1609
            stats["inactive_users"] = inactiveUsers + 1
1610
        }
1611

1612
        progress, err := learningService.GetUserProgress(ctx, user.ID)
1613
        if err != nil {
1614
            logger.Warn(ctx, "Failed to get progress for user", map[string]interface{}{"user_id": user.ID, "error": err.Error()})
1615
            continue
1616
        }
1617

1618
        if progress != nil {
1619
            totalAnswered := stats["total_questions_answered"].(int)
1620
            stats["total_questions_answered"] = totalAnswered + progress.TotalQuestions
1621

1622
            totalCorrect := stats["total_correct_answers"].(int)
1623
            stats["total_correct_answers"] = totalCorrect + progress.CorrectAnswers
1624
        }
1625
    }
1626

1627
    totalAnswered := stats["total_questions_answered"].(int)
1628
    if totalAnswered > 0 {
1629
        stats["average_accuracy"] = float64(stats["total_correct_answers"].(int)) / float64(totalAnswered) * 100.0
1630
    }
1631

1632
    return stats
1633
}
1634


			
quizapp internal handlers worker_admin_handler.go
59.4%
Statements
38/64
1
package handlers
2

3
import (
4
    "encoding/json"
5
    "fmt"
6
    "strings"
7
)
8

9
// MergeAISuggestion merges AI response into the original question map.
10
// It ensures top-level metadata from original are preserved and AI-provided
11
// content is merged into original["content"].
12
//
13
// Canonical location for `correct_answer` and `explanation` is the TOP LEVEL of
14
// the returned object. Any occurrences under `content` are removed.
15
4x
func MergeAISuggestion(original, aiResp map[string]interface{}) map[string]interface{} {
16
4x
    // copy original to avoid mutating caller's map
17
4x
    out := map[string]interface{}{}
18
4x
    b, _ := json.Marshal(original)
19
4x
    _ = json.Unmarshal(b, &out)
20
4x

21
4x
    // ensure content map exists
22
4x
    contentIface := out["content"]
23
4x
    contentMap, _ := contentIface.(map[string]interface{})
24
4x
    if contentMap == nil {
25
        contentMap = map[string]interface{}{}
26
        out["content"] = contentMap
27
    }
28

29
    // merge ai content into content map
30
4x
    if aiContentRaw, ok := aiResp["content"]; ok {
31
4x
        if aiContentMap, ok2 := aiContentRaw.(map[string]interface{}); ok2 {
32
4x
            for k, v := range aiContentMap {
33
16x
                contentMap[k] = v
34
16x
            }
35
        }
36
    }
37

38
    // Ensure answer/explanation live at TOP LEVEL on the output, not inside content
39
    // Prefer values from the AI response when present.
40
4x
    if ca, ok := aiResp["correct_answer"]; ok {
41
4x
        out["correct_answer"] = ca
42
4x
    }
43
4x
    if ex, ok := aiResp["explanation"]; ok {
44
4x
        out["explanation"] = ex
45
4x
    }
46

47
    // Remove any duplicates that may exist inside content
48
4x
    delete(contentMap, "correct_answer")
49
4x
    delete(contentMap, "explanation")
50
4x

51
4x
    if cr, ok := aiResp["change_reason"]; ok {
52
4x
        out["change_reason"] = cr
53
4x
    }
54

55
4x
    NormalizeContent(contentMap)
56
4x

57
4x
    return out
58
}
59

60
// NormalizeContent attempts to sanitize content fields: options->[]string and
61
// simple string coercions for human-readable fields. Answer/explanation stay at
62
// top level and are not touched here.
63
4x
func NormalizeContent(contentMap map[string]interface{}) {
64
4x
    // normalize options
65
4x
    if optsRaw, ok := contentMap["options"]; ok {
66
4x
        switch opts := optsRaw.(type) {
67
4x
        case []interface{}:
68
4x
            seen := map[string]bool{}
69
4x
            var out []string
70
4x
            for _, it := range opts {
71
14x
                s, ok := it.(string)
72
14x
                if !ok {
73
                    continue
74
                }
75
14x
                s = strings.TrimSpace(s)
76
14x
                if s == "" {
77
                    continue
78
                }
79
14x
                if !seen[s] {
80
14x
                    out = append(out, s)
81
14x
                    seen[s] = true
82
14x
                }
83
            }
84
4x
            contentMap["options"] = out
85
        case []string:
86
            // ok
87
        case string:
88
            var parsed []string
89
            if err := json.Unmarshal([]byte(opts), &parsed); err == nil {
90
                contentMap["options"] = parsed
91
            } else {
92
                parts := strings.FieldsFunc(opts, func(r rune) bool { return r == '\n' || r == ',' })
93
                var out []string
94
                seen := map[string]bool{}
95
                for _, p := range parts {
96
                    p = strings.TrimSpace(p)
97
                    if p == "" {
98
                        continue
99
                    }
100
                    if !seen[p] {
101
                        out = append(out, p)
102
                        seen[p] = true
103
                    }
104
                }
105
                contentMap["options"] = out
106
            }
107
        default:
108
            delete(contentMap, "options")
109
        }
110
    }
111

112
    // ensure options slice is []string
113
4x
    if optsI, ok := contentMap["options"].([]interface{}); ok {
114
        var out []string
115
        for _, it := range optsI {
116
            if s, ok := it.(string); ok {
117
                out = append(out, s)
118
            }
119
        }
120
        contentMap["options"] = out
121
    }
122

123
    // Ensure no stray correct_answer under content
124
4x
    delete(contentMap, "correct_answer")
125
4x

126
4x
    // ensure simple string fields
127
4x
    for _, k := range []string{"explanation", "question", "passage", "sentence"} {
128
16x
        if v, ok := contentMap[k]; ok {
129
4x
            switch t := v.(type) {
130
4x
            case string:
131
                // ok
132
            default:
133
                contentMap[k] = fmt.Sprint(t)
134
            }
135
        }
136
    }
137
}
138


			
quizapp internal handlers worker_admin_handler.go
0.4%
Statements
1/233
1
package handlers
2

3
import (
4
    "net/http"
5
    "strconv"
6
    "strings"
7

8
    "quizapp/internal/api"
9
    "quizapp/internal/config"
10
    "quizapp/internal/observability"
11
    "quizapp/internal/services"
12
    contextutils "quizapp/internal/utils"
13

14
    "github.com/gin-gonic/gin"
15
    "github.com/google/uuid"
16
    "go.opentelemetry.io/otel/attribute"
17
)
18

19
// AIConversationHandler handles AI conversation-related HTTP requests
20
type AIConversationHandler struct {
21
    conversationService services.ConversationServiceInterface
22
    cfg                 *config.Config
23
    logger              *observability.Logger
24
}
25

26
// NewAIConversationHandler creates a new AIConversationHandler
27
func NewAIConversationHandler(
28
    conversationService services.ConversationServiceInterface,
29
    cfg *config.Config,
30
    logger *observability.Logger,
31
13x
) *AIConversationHandler {
32
13x
    return &AIConversationHandler{
33
13x
        conversationService: conversationService,
34
13x
        cfg:                 cfg,
35
13x
        logger:              logger,
36
13x
    }
37
13x
}
38

39
// GetConversations handles GET /v1/ai/conversations
40
func (h *AIConversationHandler) GetConversations(c *gin.Context) {
41
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "get_ai_conversations")
42
    defer observability.FinishSpan(span, nil)
43

44
    userID, exists := GetUserIDFromSession(c)
45
    if !exists {
46
        HandleAppError(c, contextutils.ErrUnauthorized)
47
        return
48
    }
49

50
    // Parse query parameters
51
    limitStr := c.DefaultQuery("limit", "20")
52
    offsetStr := c.DefaultQuery("offset", "0")
53

54
    limit, err := strconv.Atoi(limitStr)
55
    if err != nil || limit < 1 || limit > 100 {
56
        HandleAppError(c, contextutils.ErrInvalidFormat)
57
        return
58
    }
59

60
    offset, err := strconv.Atoi(offsetStr)
61
    if err != nil || offset < 0 {
62
        HandleAppError(c, contextutils.ErrInvalidFormat)
63
        return
64
    }
65

66
    // Add span attributes for observability
67
    span.SetAttributes(
68
        observability.AttributeUserID(userID),
69
        attribute.Int("limit", limit),
70
        attribute.Int("offset", offset),
71
    )
72

73
    // Get conversations for the user
74
    conversations, total, err := h.conversationService.GetUserConversations(ctx, uint(userID), limit, offset)
75
    if err != nil {
76
        h.logger.Error(ctx, "Failed to get user conversations", err, map[string]interface{}{
77
            "user_id": userID,
78
            "limit":   limit,
79
            "offset":  offset,
80
        })
81
        HandleAppError(c, contextutils.WrapError(err, "failed to get conversations"))
82
        return
83
    }
84

85
    // Enrich with message counts to support UI badges without loading messages
86
    counts, err := h.conversationService.GetUserMessageCounts(ctx, uint(userID))
87
    if err != nil {
88
        h.logger.Error(ctx, "Failed to get message counts", err, map[string]interface{}{
89
            "user_id": userID,
90
        })
91
        // Not fatal; continue without counts
92
        counts = map[string]int{}
93
    }
94

95
    // Inject message_count into each conversation via a response wrapper to keep type safety
96
    type conversationWithCount struct {
97
        api.Conversation
98
        MessageCount int `json:"message_count"`
99
    }
100
    convsWithCount := make([]conversationWithCount, 0, len(conversations))
101
    for _, conv := range conversations {
102
        idStr := conv.Id.String()
103
        convsWithCount = append(convsWithCount, conversationWithCount{
104
            Conversation: conv,
105
            MessageCount: counts[idStr],
106
        })
107
    }
108

109
    // Add total count to response
110
    response := gin.H{
111
        "conversations": convsWithCount,
112
        "total":         total,
113
        "limit":         limit,
114
        "offset":        offset,
115
    }
116

117
    c.JSON(http.StatusOK, response)
118
}
119

120
// CreateConversation handles POST /v1/ai/conversations
121
func (h *AIConversationHandler) CreateConversation(c *gin.Context) {
122
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "create_ai_conversation")
123
    defer observability.FinishSpan(span, nil)
124

125
    userID, exists := GetUserIDFromSession(c)
126
    if !exists {
127
        HandleAppError(c, contextutils.ErrUnauthorized)
128
        return
129
    }
130

131
    // Parse request body
132
    var req api.CreateConversationRequest
133
    if err := c.ShouldBindJSON(&req); err != nil {
134
        HandleAppError(c, contextutils.NewAppErrorWithCause(
135
            contextutils.ErrorCodeInvalidInput,
136
            contextutils.SeverityWarn,
137
            "Invalid request body",
138
            "",
139
            err,
140
        ))
141
        return
142
    }
143

144
    // Add span attributes for observability
145
    span.SetAttributes(
146
        observability.AttributeUserID(userID),
147
        attribute.String("conversation_title", req.Title),
148
    )
149

150
    // Create conversation
151
    conversation, err := h.conversationService.CreateConversation(ctx, uint(userID), &req)
152
    if err != nil {
153
        h.logger.Error(ctx, "Failed to create conversation", err, map[string]interface{}{
154
            "user_id": userID,
155
            "title":   req.Title,
156
        })
157
        HandleAppError(c, contextutils.WrapError(err, "failed to create conversation"))
158
        return
159
    }
160

161
    c.JSON(http.StatusCreated, conversation)
162
}
163

164
// GetConversation handles GET /v1/ai/conversations/{id}
165
func (h *AIConversationHandler) GetConversation(c *gin.Context) {
166
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "get_ai_conversation")
167
    defer observability.FinishSpan(span, nil)
168

169
    userID, exists := GetUserIDFromSession(c)
170
    if !exists {
171
        HandleAppError(c, contextutils.ErrUnauthorized)
172
        return
173
    }
174

175
    // Parse conversation ID parameter
176
    conversationID := c.Param("id")
177
    if conversationID == "" {
178
        HandleAppError(c, contextutils.ErrMissingRequired)
179
        return
180
    }
181

182
    // Validate UUID format
183
    if _, err := uuid.Parse(conversationID); err != nil {
184
        HandleAppError(c, contextutils.ErrInvalidFormat)
185
        return
186
    }
187

188
    // Add span attributes for observability
189
    span.SetAttributes(
190
        observability.AttributeUserID(userID),
191
        attribute.String("conversation_id", conversationID),
192
    )
193

194
    // Get conversation with messages
195
    conversation, err := h.conversationService.GetConversation(ctx, conversationID, uint(userID))
196
    if err != nil {
197
        h.logger.Error(ctx, "Failed to get conversation", err, map[string]interface{}{
198
            "user_id":         userID,
199
            "conversation_id": conversationID,
200
        })
201

202
        // Check if it's a conversation not found error
203
        if strings.Contains(err.Error(), "conversation not found") {
204
            HandleAppError(c, contextutils.ErrRecordNotFound)
205
            return
206
        }
207

208
        HandleAppError(c, contextutils.WrapError(err, "failed to get conversation"))
209
        return
210
    }
211

212
    c.JSON(http.StatusOK, conversation)
213
}
214

215
// UpdateConversation handles PUT /v1/ai/conversations/{id}
216
func (h *AIConversationHandler) UpdateConversation(c *gin.Context) {
217
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "update_ai_conversation")
218
    defer observability.FinishSpan(span, nil)
219

220
    userID, exists := GetUserIDFromSession(c)
221
    if !exists {
222
        HandleAppError(c, contextutils.ErrUnauthorized)
223
        return
224
    }
225

226
    // Parse conversation ID parameter
227
    conversationID := c.Param("id")
228
    if conversationID == "" {
229
        HandleAppError(c, contextutils.ErrMissingRequired)
230
        return
231
    }
232

233
    // Validate UUID format
234
    if _, err := uuid.Parse(conversationID); err != nil {
235
        HandleAppError(c, contextutils.ErrInvalidFormat)
236
        return
237
    }
238

239
    // Parse request body
240
    var req api.UpdateConversationRequest
241
    if err := c.ShouldBindJSON(&req); err != nil {
242
        HandleAppError(c, contextutils.NewAppErrorWithCause(
243
            contextutils.ErrorCodeInvalidInput,
244
            contextutils.SeverityWarn,
245
            "Invalid request body",
246
            "",
247
            err,
248
        ))
249
        return
250
    }
251

252
    // Add span attributes for observability
253
    span.SetAttributes(
254
        observability.AttributeUserID(userID),
255
        attribute.String("conversation_id", conversationID),
256
        attribute.String("new_title", req.Title),
257
    )
258

259
    // Update conversation
260
    conversation, err := h.conversationService.UpdateConversation(ctx, conversationID, uint(userID), &req)
261
    if err != nil {
262
        h.logger.Error(ctx, "Failed to update conversation", err, map[string]interface{}{
263
            "user_id":         userID,
264
            "conversation_id": conversationID,
265
            "new_title":       req.Title,
266
        })
267

268
        // Check if it's a conversation not found error
269
        if strings.Contains(err.Error(), "conversation not found") {
270
            HandleAppError(c, contextutils.ErrRecordNotFound)
271
            return
272
        }
273

274
        HandleAppError(c, contextutils.WrapError(err, "failed to update conversation"))
275
        return
276
    }
277

278
    c.JSON(http.StatusOK, conversation)
279
}
280

281
// DeleteConversation handles DELETE /v1/ai/conversations/{id}
282
func (h *AIConversationHandler) DeleteConversation(c *gin.Context) {
283
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "delete_ai_conversation")
284
    defer observability.FinishSpan(span, nil)
285

286
    userID, exists := GetUserIDFromSession(c)
287
    if !exists {
288
        HandleAppError(c, contextutils.ErrUnauthorized)
289
        return
290
    }
291

292
    // Parse conversation ID parameter
293
    conversationID := c.Param("id")
294
    if conversationID == "" {
295
        HandleAppError(c, contextutils.ErrMissingRequired)
296
        return
297
    }
298

299
    // Validate UUID format
300
    if _, err := uuid.Parse(conversationID); err != nil {
301
        HandleAppError(c, contextutils.ErrInvalidFormat)
302
        return
303
    }
304

305
    // Add span attributes for observability
306
    span.SetAttributes(
307
        observability.AttributeUserID(userID),
308
        attribute.String("conversation_id", conversationID),
309
    )
310

311
    // Delete conversation and all its messages
312
    err := h.conversationService.DeleteConversation(ctx, conversationID, uint(userID))
313
    if err != nil {
314
        h.logger.Error(ctx, "Failed to delete conversation", err, map[string]interface{}{
315
            "user_id":         userID,
316
            "conversation_id": conversationID,
317
        })
318

319
        // Check if it's a conversation not found error
320
        if strings.Contains(err.Error(), "conversation not found") {
321
            HandleAppError(c, contextutils.ErrRecordNotFound)
322
            return
323
        }
324

325
        HandleAppError(c, contextutils.WrapError(err, "failed to delete conversation"))
326
        return
327
    }
328

329
    c.Status(http.StatusNoContent)
330
}
331

332
// AddMessage handles POST /v1/ai/conversations/{conversationId}/messages
333
func (h *AIConversationHandler) AddMessage(c *gin.Context) {
334
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "add_ai_message")
335
    defer observability.FinishSpan(span, nil)
336

337
    userID, exists := GetUserIDFromSession(c)
338
    if !exists {
339
        HandleAppError(c, contextutils.ErrUnauthorized)
340
        return
341
    }
342

343
    // Parse conversation ID parameter
344
    conversationID := c.Param("conversationId")
345
    if conversationID == "" {
346
        HandleAppError(c, contextutils.ErrMissingRequired)
347
        return
348
    }
349

350
    // Validate UUID format
351
    if _, err := uuid.Parse(conversationID); err != nil {
352
        HandleAppError(c, contextutils.ErrInvalidFormat)
353
        return
354
    }
355

356
    // Parse request body
357
    var req api.CreateMessageRequest
358
    if err := c.ShouldBindJSON(&req); err != nil {
359
        HandleAppError(c, contextutils.NewAppErrorWithCause(
360
            contextutils.ErrorCodeInvalidInput,
361
            contextutils.SeverityWarn,
362
            "Invalid request body",
363
            "",
364
            err,
365
        ))
366
        return
367
    }
368

369
    // Calculate content length for observability
370
    contentLength := 0
371
    if req.Content.Text != nil {
372
        contentLength = len(*req.Content.Text)
373
    }
374

375
    // Add span attributes for observability
376
    span.SetAttributes(
377
        observability.AttributeUserID(userID),
378
        attribute.String("conversation_id", conversationID),
379
        attribute.String("message_role", string(req.Role)),
380
        attribute.Int("message_content_length", contentLength),
381
    )
382

383
    // Add message to conversation
384
    createdMessage, err := h.conversationService.AddMessage(ctx, conversationID, uint(userID), &req)
385
    if err != nil {
386
        h.logger.Error(ctx, "Failed to add message to conversation", err, map[string]interface{}{
387
            "user_id":         userID,
388
            "conversation_id": conversationID,
389
            "message_role":    req.Role,
390
        })
391

392
        // Check if it's a conversation not found error
393
        if strings.Contains(err.Error(), "conversation not found") {
394
            HandleAppError(c, contextutils.ErrRecordNotFound)
395
            return
396
        }
397

398
        HandleAppError(c, contextutils.WrapError(err, "failed to add message"))
399
        return
400
    }
401

402
    c.JSON(http.StatusCreated, createdMessage)
403
}
404

405
// SearchConversations handles GET /v1/ai/search
406
func (h *AIConversationHandler) SearchConversations(c *gin.Context) {
407
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "search_ai_conversations")
408
    defer observability.FinishSpan(span, nil)
409

410
    userID, exists := GetUserIDFromSession(c)
411
    if !exists {
412
        HandleAppError(c, contextutils.ErrUnauthorized)
413
        return
414
    }
415

416
    // Parse query parameters
417
    query := c.Query("q")
418
    if query == "" {
419
        HandleAppError(c, contextutils.ErrInvalidInput)
420
        return
421
    }
422

423
    limitStr := c.DefaultQuery("limit", "20")
424
    offsetStr := c.DefaultQuery("offset", "0")
425

426
    limit, err := strconv.Atoi(limitStr)
427
    if err != nil || limit < 1 || limit > 100 {
428
        HandleAppError(c, contextutils.ErrInvalidFormat)
429
        return
430
    }
431

432
    offset, err := strconv.Atoi(offsetStr)
433
    if err != nil || offset < 0 {
434
        HandleAppError(c, contextutils.ErrInvalidFormat)
435
        return
436
    }
437

438
    // Add span attributes for observability
439
    span.SetAttributes(
440
        observability.AttributeUserID(userID),
441
        attribute.String("search_query", query),
442
        attribute.Int("limit", limit),
443
        attribute.Int("offset", offset),
444
    )
445

446
    // Search conversations
447
    conversations, total, err := h.conversationService.SearchConversations(ctx, uint(userID), query, limit, offset)
448
    if err != nil {
449
        h.logger.Error(ctx, "Failed to search conversations", err, map[string]interface{}{
450
            "user_id": userID,
451
            "query":   query,
452
            "limit":   limit,
453
            "offset":  offset,
454
        })
455
        HandleAppError(c, contextutils.WrapError(err, "failed to search conversations"))
456
        return
457
    }
458

459
    // Add total count to response
460
    response := gin.H{
461
        "conversations": conversations,
462
        "query":         query,
463
        "total":         total,
464
        "limit":         limit,
465
        "offset":        offset,
466
    }
467

468
    c.JSON(http.StatusOK, response)
469
}
470

471
// ToggleMessageBookmark handles PUT /v1/ai/conversations/bookmark
472
func (h *AIConversationHandler) ToggleMessageBookmark(c *gin.Context) {
473
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "toggle_message_bookmark")
474
    defer observability.FinishSpan(span, nil)
475

476
    userID, exists := GetUserIDFromSession(c)
477
    if !exists {
478
        HandleAppError(c, contextutils.ErrUnauthorized)
479
        return
480
    }
481

482
    // Parse request body
483
    var req struct {
484
        ConversationID string `json:"conversation_id" binding:"required"`
485
        MessageID      string `json:"message_id" binding:"required"`
486
    }
487
    if err := c.ShouldBindJSON(&req); err != nil {
488
        HandleAppError(c, contextutils.NewAppErrorWithCause(
489
            contextutils.ErrorCodeInvalidInput,
490
            contextutils.SeverityWarn,
491
            "Invalid request body",
492
            "",
493
            err,
494
        ))
495
        return
496
    }
497

498
    // Validate UUID formats
499
    if _, err := uuid.Parse(req.ConversationID); err != nil {
500
        HandleAppError(c, contextutils.ErrInvalidFormat)
501
        return
502
    }
503
    if _, err := uuid.Parse(req.MessageID); err != nil {
504
        HandleAppError(c, contextutils.ErrInvalidFormat)
505
        return
506
    }
507

508
    // Add span attributes for observability
509
    span.SetAttributes(
510
        observability.AttributeUserID(userID),
511
        attribute.String("conversation_id", req.ConversationID),
512
        attribute.String("message_id", req.MessageID),
513
    )
514

515
    // Toggle message bookmark
516
    newBookmarkedStatus, err := h.conversationService.ToggleMessageBookmark(ctx, req.ConversationID, req.MessageID, uint(userID))
517
    if err != nil {
518
        h.logger.Error(ctx, "Failed to toggle message bookmark", err, map[string]interface{}{
519
            "user_id":         userID,
520
            "conversation_id": req.ConversationID,
521
            "message_id":      req.MessageID,
522
        })
523

524
        // Check if it's a conversation or message not found error
525
        if strings.Contains(err.Error(), "not found") {
526
            HandleAppError(c, contextutils.ErrRecordNotFound)
527
            return
528
        }
529

530
        HandleAppError(c, contextutils.WrapError(err, "failed to toggle message bookmark"))
531
        return
532
    }
533

534
    c.JSON(http.StatusOK, gin.H{
535
        "bookmarked": newBookmarkedStatus,
536
    })
537
}
538

539
// GetBookmarkedMessages handles GET /v1/ai/bookmarks
540
func (h *AIConversationHandler) GetBookmarkedMessages(c *gin.Context) {
541
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "get_bookmarked_messages")
542
    defer observability.FinishSpan(span, nil)
543

544
    userID, exists := GetUserIDFromSession(c)
545
    if !exists {
546
        HandleAppError(c, contextutils.ErrUnauthorized)
547
        return
548
    }
549

550
    // Parse query parameters
551
    query := c.DefaultQuery("q", "")
552
    limitStr := c.DefaultQuery("limit", "20")
553
    offsetStr := c.DefaultQuery("offset", "0")
554

555
    limit, err := strconv.Atoi(limitStr)
556
    if err != nil || limit < 1 || limit > 100 {
557
        HandleAppError(c, contextutils.ErrInvalidFormat)
558
        return
559
    }
560

561
    offset, err := strconv.Atoi(offsetStr)
562
    if err != nil || offset < 0 {
563
        HandleAppError(c, contextutils.ErrInvalidFormat)
564
        return
565
    }
566

567
    // Add span attributes for observability
568
    span.SetAttributes(
569
        observability.AttributeUserID(userID),
570
        attribute.String("search_query", query),
571
        attribute.Int("limit", limit),
572
        attribute.Int("offset", offset),
573
    )
574

575
    // Get bookmarked messages
576
    messages, total, err := h.conversationService.GetBookmarkedMessages(ctx, uint(userID), query, limit, offset)
577
    if err != nil {
578
        h.logger.Error(ctx, "Failed to get bookmarked messages", err, map[string]interface{}{
579
            "user_id": userID,
580
            "query":   query,
581
            "limit":   limit,
582
            "offset":  offset,
583
        })
584
        HandleAppError(c, contextutils.WrapError(err, "failed to get bookmarked messages"))
585
        return
586
    }
587

588
    // Add total count to response
589
    response := gin.H{
590
        "messages": messages,
591
        "query":    query,
592
        "total":    total,
593
        "limit":    limit,
594
        "offset":   offset,
595
    }
596

597
    c.JSON(http.StatusOK, response)
598
}
599


			
quizapp internal handlers worker_admin_handler.go
1.1%
Statements
1/87
1
package handlers
2

3
import (
4
    "net/http"
5
    "strconv"
6

7
    "quizapp/internal/api"
8
    "quizapp/internal/middleware"
9
    "quizapp/internal/observability"
10
    "quizapp/internal/services"
11
    contextutils "quizapp/internal/utils"
12

13
    "github.com/gin-gonic/gin"
14
    "go.opentelemetry.io/otel/attribute"
15
)
16

17
// AuthAPIKeyHandler handles authentication API key related HTTP requests
18
type AuthAPIKeyHandler struct {
19
    apiKeyService services.AuthAPIKeyServiceInterface
20
    logger        *observability.Logger
21
}
22

23
// NewAuthAPIKeyHandler creates a new AuthAPIKeyHandler instance
24
13x
func NewAuthAPIKeyHandler(apiKeyService services.AuthAPIKeyServiceInterface, logger *observability.Logger) *AuthAPIKeyHandler {
25
13x
    return &AuthAPIKeyHandler{
26
13x
        apiKeyService: apiKeyService,
27
13x
        logger:        logger,
28
13x
    }
29
13x
}
30

31
// CreateAPIKey handles POST /v1/api-keys
32
func (h *AuthAPIKeyHandler) CreateAPIKey(c *gin.Context) {
33
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "CreateAPIKey")
34
    defer observability.FinishSpan(span, nil)
35

36
    // Get user ID from context (set by auth middleware)
37
    userID, exists := c.Get(middleware.UserIDKey)
38
    if !exists {
39
        HandleAppError(c, contextutils.ErrUnauthorized)
40
        return
41
    }
42

43
    userIDInt, ok := userID.(int)
44
    if !ok {
45
        HandleAppError(c, contextutils.ErrInternalError)
46
        return
47
    }
48

49
    span.SetAttributes(attribute.Int("user_id", userIDInt))
50

51
    // Parse request body
52
    var req struct {
53
        KeyName         string `json:"key_name" binding:"required"`
54
        PermissionLevel string `json:"permission_level" binding:"required"`
55
    }
56

57
    if err := c.ShouldBindJSON(&req); err != nil {
58
        HandleAppError(c, contextutils.NewAppErrorWithCause(
59
            contextutils.ErrorCodeInvalidInput,
60
            contextutils.SeverityWarn,
61
            "Invalid request body",
62
            "",
63
            err,
64
        ))
65
        return
66
    }
67

68
    span.SetAttributes(
69
        attribute.String("key_name", req.KeyName),
70
        attribute.String("permission_level", req.PermissionLevel),
71
    )
72

73
    // Create API key
74
    apiKey, rawKey, err := h.apiKeyService.CreateAPIKey(ctx, userIDInt, req.KeyName, req.PermissionLevel)
75
    if err != nil {
76
        h.logger.Error(ctx, "Failed to create API key", err, map[string]interface{}{
77
            "user_id":          userIDInt,
78
            "key_name":         req.KeyName,
79
            "permission_level": req.PermissionLevel,
80
        })
81
        HandleAppError(c, err)
82
        return
83
    }
84

85
    span.SetAttributes(attribute.Int("api_key_id", apiKey.ID))
86

87
    // Return the full key ONCE (this is the only time it will be shown)
88
    c.JSON(http.StatusCreated, gin.H{
89
        "id":               apiKey.ID,
90
        "key_name":         apiKey.KeyName,
91
        "key":              rawKey, // Full key - only shown once!
92
        "key_prefix":       apiKey.KeyPrefix,
93
        "permission_level": apiKey.PermissionLevel,
94
        "created_at":       apiKey.CreatedAt,
95
        "message":          "Save this API key now. You won't be able to see it again!",
96
    })
97
}
98

99
// ListAPIKeys handles GET /v1/api-keys
100
func (h *AuthAPIKeyHandler) ListAPIKeys(c *gin.Context) {
101
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "ListAPIKeys")
102
    defer observability.FinishSpan(span, nil)
103

104
    // Get user ID from context (set by auth middleware)
105
    userID, exists := c.Get(middleware.UserIDKey)
106
    if !exists {
107
        HandleAppError(c, contextutils.ErrUnauthorized)
108
        return
109
    }
110

111
    userIDInt, ok := userID.(int)
112
    if !ok {
113
        HandleAppError(c, contextutils.ErrInternalError)
114
        return
115
    }
116

117
    span.SetAttributes(attribute.Int("user_id", userIDInt))
118

119
    // List API keys
120
    apiKeys, err := h.apiKeyService.ListAPIKeys(ctx, userIDInt)
121
    if err != nil {
122
        h.logger.Error(ctx, "Failed to list API keys", err, map[string]interface{}{"user_id": userIDInt})
123
        HandleAppError(c, err)
124
        return
125
    }
126

127
    span.SetAttributes(attribute.Int("count", len(apiKeys)))
128

129
    // Convert to generated API types to ensure schema-correct serialization
130
    apiSummaries := convertAuthAPIKeysToAPI(apiKeys)
131
    count := len(apiSummaries)
132
    resp := api.APIKeysListResponse{
133
        ApiKeys: &apiSummaries,
134
        Count:   &count,
135
    }
136
    c.JSON(http.StatusOK, resp)
137
}
138

139
// DeleteAPIKey handles DELETE /v1/api-keys/:id
140
func (h *AuthAPIKeyHandler) DeleteAPIKey(c *gin.Context) {
141
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "DeleteAPIKey")
142
    defer observability.FinishSpan(span, nil)
143

144
    // Get user ID from context (set by auth middleware)
145
    userID, exists := c.Get(middleware.UserIDKey)
146
    if !exists {
147
        HandleAppError(c, contextutils.ErrUnauthorized)
148
        return
149
    }
150

151
    userIDInt, ok := userID.(int)
152
    if !ok {
153
        HandleAppError(c, contextutils.ErrInternalError)
154
        return
155
    }
156

157
    // Get key ID from URL parameter
158
    keyIDStr := c.Param("id")
159
    keyID, err := strconv.Atoi(keyIDStr)
160
    if err != nil {
161
        HandleAppError(c, contextutils.NewAppErrorWithCause(
162
            contextutils.ErrorCodeInvalidInput,
163
            contextutils.SeverityWarn,
164
            "Invalid API key ID",
165
            "",
166
            err,
167
        ))
168
        return
169
    }
170

171
    span.SetAttributes(
172
        attribute.Int("user_id", userIDInt),
173
        attribute.Int("key_id", keyID),
174
    )
175

176
    // Delete API key
177
    err = h.apiKeyService.DeleteAPIKey(ctx, userIDInt, keyID)
178
    if err != nil {
179
        h.logger.Error(ctx, "Failed to delete API key", err, map[string]interface{}{
180
            "user_id": userIDInt,
181
            "key_id":  keyID,
182
        })
183
        HandleAppError(c, err)
184
        return
185
    }
186

187
    c.JSON(http.StatusOK, gin.H{
188
        "success": true,
189
        "message": "API key deleted successfully",
190
    })
191
}
192

193
// TestRead handles GET /v1/api-keys/test-read
194
// Requires API key auth (readonly or full). Returns basic info for verification.
195
func (h *AuthAPIKeyHandler) TestRead(c *gin.Context) {
196
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "TestAPIKeyRead")
197
    defer observability.FinishSpan(span, nil)
198

199
    // Extract context set by middleware
200
    userID := c.GetInt(middleware.UserIDKey)
201
    username := c.GetString(middleware.UsernameKey)
202
    apiKeyID := c.GetInt(middleware.APIKeyIDKey)
203

204
    // Fetch permission level using the key id
205
    var permissionLevel string
206
    if apiKeyID != 0 && userID != 0 {
207
        if apiKey, err := h.apiKeyService.GetAPIKeyByID(ctx, userID, apiKeyID); err == nil && apiKey != nil {
208
            permissionLevel = apiKey.PermissionLevel
209
        }
210
    }
211

212
    c.JSON(http.StatusOK, gin.H{
213
        "ok":               true,
214
        "user_id":          userID,
215
        "username":         username,
216
        "permission_level": permissionLevel,
217
        "api_key_id":       apiKeyID,
218
        "method":           c.Request.Method,
219
    })
220
}
221

222
// TestWrite handles POST /v1/api-keys/test-write
223
// Requires API key auth. Middleware enforces permission by HTTP method.
224
func (h *AuthAPIKeyHandler) TestWrite(c *gin.Context) {
225
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "TestAPIKeyWrite")
226
    defer observability.FinishSpan(span, nil)
227

228
    userID := c.GetInt(middleware.UserIDKey)
229
    username := c.GetString(middleware.UsernameKey)
230
    apiKeyID := c.GetInt(middleware.APIKeyIDKey)
231

232
    var permissionLevel string
233
    if apiKeyID != 0 && userID != 0 {
234
        if apiKey, err := h.apiKeyService.GetAPIKeyByID(ctx, userID, apiKeyID); err == nil && apiKey != nil {
235
            permissionLevel = apiKey.PermissionLevel
236
        }
237
    }
238

239
    c.JSON(http.StatusOK, gin.H{
240
        "ok":               true,
241
        "user_id":          userID,
242
        "username":         username,
243
        "permission_level": permissionLevel,
244
        "api_key_id":       apiKeyID,
245
        "method":           c.Request.Method,
246
    })
247
}
248


			
quizapp internal handlers worker_admin_handler.go
73.2%
Statements
213/291
1
package handlers
2

3
import (
4
    "crypto/rand"
5
    "errors"
6
    "net/http"
7
    "regexp"
8
    "strings"
9
    "time"
10

11
    "quizapp/internal/api"
12
    "quizapp/internal/config"
13
    "quizapp/internal/middleware"
14
    "quizapp/internal/observability"
15
    "quizapp/internal/services"
16
    contextutils "quizapp/internal/utils"
17

18
    "github.com/gin-contrib/sessions"
19
    "github.com/gin-gonic/gin"
20
    openapi_types "github.com/oapi-codegen/runtime/types"
21
    "go.opentelemetry.io/otel/attribute"
22
)
23

24
// AuthHandler handles authentication related HTTP requests
25
type AuthHandler struct {
26
    userService  services.UserServiceInterface
27
    oauthService *services.OAuthService
28
    config       *config.Config
29
    logger       *observability.Logger
30
}
31

32
// NewAuthHandler creates a new AuthHandler instance
33
55x
func NewAuthHandler(userService services.UserServiceInterface, oauthService *services.OAuthService, cfg *config.Config, logger *observability.Logger) *AuthHandler {
34
55x
    return &AuthHandler{
35
55x
        userService:  userService,
36
55x
        oauthService: oauthService,
37
55x
        config:       cfg,
38
55x
        logger:       logger,
39
55x
    }
40
55x
}
41

42
// Login handles user login requests
43
193x
func (h *AuthHandler) Login(c *gin.Context) {
44
193x
    _, span := observability.TraceHandlerFunction(c.Request.Context(), "login")
45
193x
    defer observability.FinishSpan(span, nil)
46
193x

47
193x
    var req api.LoginRequest
48
193x
    if err := c.ShouldBindJSON(&req); err != nil {
49
3x
        HandleAppError(c, contextutils.NewAppErrorWithCause(
50
3x
            contextutils.ErrorCodeInvalidInput,
51
3x
            contextutils.SeverityWarn,
52
3x
            "Invalid request body",
53
3x
            "",
54
3x
            err,
55
3x
        ))
56
3x
        return
57
3x
    }
58

59
    // Set span attributes for observability
60
190x
    span.SetAttributes(
61
190x
        attribute.String("auth.username", req.Username),
62
190x
        attribute.Bool("auth.password_provided", req.Password != ""),
63
190x
    )
64
190x

65
190x
    // Authenticate user against database
66
190x
    user, err := h.userService.AuthenticateUser(c.Request.Context(), req.Username, req.Password)
67
190x
    if err != nil {
68
10x
        h.logger.Error(c.Request.Context(), "Authentication failed for user", err, map[string]interface{}{"username": req.Username})
69
10x
        HandleAppError(c, contextutils.ErrInvalidCredentials)
70
10x
        return
71
10x
    }
72

73
180x
    if user == nil {
74
        HandleAppError(c, contextutils.ErrInvalidCredentials)
75
        return
76
    }
77

78
    // Update span attributes with user info
79
180x
    span.SetAttributes(
80
180x
        attribute.Int("user.id", user.ID),
81
180x
        attribute.String("user.username", user.Username),
82
180x
        attribute.Bool("user.email_provided", user.Email.Valid),
83
180x
        attribute.String("user.language", user.PreferredLanguage.String),
84
180x
        attribute.String("user.level", user.CurrentLevel.String),
85
180x
    )
86
180x

87
180x
    // Update last active
88
180x
    if err := h.userService.UpdateLastActive(c.Request.Context(), user.ID); err != nil {
89
        // Log error but don't fail login
90
        // In production, you'd want proper logging here
91
        h.logger.Warn(c.Request.Context(), "Failed to update last active for user", map[string]interface{}{"user_id": user.ID, "error": err.Error()})
92
    }
93

94
    // Create session
95
180x
    session := sessions.Default(c)
96
180x
    session.Set(middleware.UserIDKey, user.ID)
97
180x
    session.Set(middleware.UsernameKey, user.Username)
98
180x

99
180x
    if err := session.Save(); err != nil {
100
        h.logger.Error(c.Request.Context(), "Failed to save session", err, map[string]interface{}{"error": err.Error()})
101
        HandleAppError(c, contextutils.WrapError(err, "failed to create session"))
102
        return
103
    }
104

105
    // Convert models.User to api.User with proper API key checking
106
180x
    apiUser := convertUserToAPIWithService(c.Request.Context(), user, h.userService)
107
180x

108
180x
    // Return user info (without API key)
109
180x
    c.JSON(http.StatusOK, api.LoginResponse{
110
180x
        Success: boolPtr(true),
111
180x
        Message: stringPtr("Login successful"),
112
180x
        User:    &apiUser,
113
180x
    })
114
}
115

116
// Logout handles user logout requests
117
4x
func (h *AuthHandler) Logout(c *gin.Context) {
118
4x
    _, span := observability.TraceHandlerFunction(c.Request.Context(), "logout")
119
4x
    defer observability.FinishSpan(span, nil)
120
4x

121
4x
    // Get user info before clearing session for tracing
122
4x
    session := sessions.Default(c)
123
4x
    userID := session.Get(middleware.UserIDKey)
124
4x
    username := session.Get(middleware.UsernameKey)
125
4x

126
4x
    // Set span attributes
127
4x
    if userID != nil {
128
1x
        span.SetAttributes(attribute.Int("user.id", userID.(int)))
129
1x
    }
130
4x
    if username != nil {
131
        span.SetAttributes(attribute.String("user.username", username.(string)))
132
    }
133

134
4x
    session.Clear()
135
4x

136
4x
    if err := session.Save(); err != nil {
137
        HandleAppError(c, contextutils.WrapError(err, "failed to clear session"))
138
        return
139
    }
140

141
4x
    c.JSON(http.StatusOK, api.SuccessResponse{
142
4x
        Success: true,
143
4x
        Message: stringPtr("Logout successful"),
144
4x
    })
145
}
146

147
// Status returns the current authentication status
148
11x
func (h *AuthHandler) Status(c *gin.Context) {
149
11x
    _, span := observability.TraceHandlerFunction(c.Request.Context(), "status")
150
11x
    defer observability.FinishSpan(span, nil)
151
11x

152
11x
    session := sessions.Default(c)
153
11x
    userID := session.Get(middleware.UserIDKey)
154
11x

155
11x
    if userID == nil {
156
6x
        span.SetAttributes(attribute.Bool("auth.authenticated", false))
157
6x
        c.JSON(http.StatusOK, gin.H{
158
6x
            "authenticated": false,
159
6x
            "user":          nil,
160
6x
        })
161
6x
        return
162
6x
    }
163

164
5x
    span.SetAttributes(
165
5x
        attribute.Bool("auth.authenticated", true),
166
5x
        attribute.Int("user.id", userID.(int)),
167
5x
    )
168
5x

169
5x
    user, err := h.userService.GetUserByID(c.Request.Context(), userID.(int))
170
5x
    if err != nil {
171
        h.logger.Error(c.Request.Context(), "Error getting user by ID", err, map[string]interface{}{"user_id": userID.(int)})
172
        HandleAppError(c, contextutils.ErrInternalError)
173
        return
174
    }
175

176
5x
    if user == nil {
177
        // User not found, clear session
178
        session.Clear()
179
        if err := session.Save(); err != nil {
180
            h.logger.Error(c.Request.Context(), "Error saving session", err, map[string]interface{}{"error": err.Error()})
181
        }
182
        span.SetAttributes(attribute.Bool("auth.user_found", false))
183
        c.JSON(http.StatusOK, gin.H{
184
            "authenticated": false,
185
            "user":          nil,
186
        })
187
        return
188
    }
189

190
    // Update span attributes with user info
191
5x
    span.SetAttributes(
192
5x
        attribute.Bool("auth.user_found", true),
193
5x
        attribute.String("user.username", user.Username),
194
5x
        attribute.Bool("user.email_provided", user.Email.Valid),
195
5x
        attribute.String("user.language", user.PreferredLanguage.String),
196
5x
        attribute.String("user.level", user.CurrentLevel.String),
197
5x
        attribute.Bool("user.ai_enabled", user.AIEnabled.Bool),
198
5x
        attribute.String("user.ai_provider", user.AIProvider.String),
199
5x
        attribute.String("user.ai_model", user.AIModel.String),
200
5x
    )
201
5x

202
5x
    // Update last active timestamp
203
5x
    if err := h.userService.UpdateLastActive(c.Request.Context(), user.ID); err != nil {
204
        h.logger.Error(c.Request.Context(), "Error updating last active", err, map[string]interface{}{"user_id": user.ID})
205
        // Don't fail the request for this error
206
    }
207

208
    // Convert models.User to api.User with proper API key checking
209
5x
    apiUser := convertUserToAPIWithService(c.Request.Context(), user, h.userService)
210
5x

211
5x
    c.JSON(http.StatusOK, gin.H{
212
5x
        "authenticated": true,
213
5x
        "user":          &apiUser,
214
5x
    })
215
}
216

217
// Check is a lightweight auth-check endpoint intended for reverse proxy auth_request.
218
// It requires authentication via middleware and returns 204 when authenticated.
219
// Unauthenticated requests are rejected by the RequireAuth middleware with 401.
220
func (h *AuthHandler) Check(c *gin.Context) {
221
    // If we reached here, authentication succeeded in middleware
222
    c.Status(http.StatusNoContent)
223
}
224

225
// Signup handles user registration requests
226
26x
func (h *AuthHandler) Signup(c *gin.Context) {
227
26x
    _, span := observability.TraceHandlerFunction(c.Request.Context(), "signup")
228
26x
    defer observability.FinishSpan(span, nil)
229
26x

230
26x
    // Check if signups are disabled
231
26x
    if h.config != nil && h.config.IsSignupDisabled() {
232
1x
        span.SetAttributes(attribute.Bool("auth.signups_disabled", true))
233
1x
        HandleAppError(c, contextutils.ErrForbidden)
234
1x
        return
235
1x
    }
236

237
24x
    span.SetAttributes(attribute.Bool("auth.signups_disabled", false))
238
24x

239
24x
    var req api.UserCreateRequest
240
24x
    if err := c.ShouldBindJSON(&req); err != nil {
241
1x
        if errors.Is(err, openapi_types.ErrValidationEmail) {
242
1x
            HandleAppError(c, contextutils.ErrInvalidInput)
243
1x
            return
244
1x
        }
245
        HandleAppError(c, contextutils.NewAppErrorWithCause(
246
            contextutils.ErrorCodeInvalidInput,
247
            contextutils.SeverityWarn,
248
            "Invalid request body",
249
            "",
250
            err,
251
        ))
252
        return
253
    }
254

255
    // Set span attributes for request data
256
22x
    span.SetAttributes(
257
22x
        attribute.String("signup.username", req.Username),
258
22x
        attribute.Bool("signup.password_provided", req.Password != ""),
259
22x
        attribute.Bool("signup.email_provided", req.Email != nil && *req.Email != ""),
260
22x
        attribute.Bool("signup.language_provided", req.PreferredLanguage != nil && *req.PreferredLanguage != ""),
261
22x
        attribute.Bool("signup.level_provided", req.CurrentLevel != nil && *req.CurrentLevel != ""),
262
22x
        attribute.Bool("signup.timezone_provided", req.Timezone != nil && *req.Timezone != ""),
263
22x
    )
264
22x

265
22x
    // Validate required fields
266
22x
    if req.Username == "" {
267
1x
        HandleAppError(c, contextutils.ErrMissingRequired)
268
1x
        return
269
1x
    }
270

271
20x
    if req.Password == "" {
272
1x
        HandleAppError(c, contextutils.ErrMissingRequired)
273
1x
        return
274
1x
    }
275

276
18x
    if req.Email == nil || *req.Email == "" {
277
1x
        HandleAppError(c, contextutils.ErrMissingRequired)
278
1x
        return
279
1x
    }
280

281
    // Validate username format (3-50 characters, alphanumeric + underscore)
282
16x
    if len(req.Username) < 3 || len(req.Username) > 50 {
283
1x
        HandleAppError(c, contextutils.ErrInvalidFormat)
284
1x
        return
285
1x
    }
286

287
14x
    usernameRegex := regexp.MustCompile(`^[a-zA-Z0-9_]+$`)
288
14x
    if !usernameRegex.MatchString(req.Username) {
289
1x
        HandleAppError(c, contextutils.ErrInvalidFormat)
290
1x
        return
291
1x
    }
292

293
    // Validate password (minimum 8 characters)
294
12x
    if len(req.Password) < 8 {
295
1x
        HandleAppError(c, contextutils.ErrInvalidFormat)
296
1x
        return
297
1x
    }
298

299
    // Validate email format (convert to string)
300
10x
    if !contextutils.IsValidEmail(string(*req.Email)) {
301
        HandleAppError(c, contextutils.ErrInvalidFormat)
302
        return
303
    }
304

305
    // Normalize email to lowercase
306
10x
    email := strings.ToLower(string(*req.Email))
307
10x

308
10x
    h.logger.Info(c.Request.Context(), "Attempting signup for user", map[string]interface{}{"username": req.Username, "email": email})
309
10x

310
10x
    // Check if username already exists
311
10x
    existingUser, err := h.userService.GetUserByUsername(c.Request.Context(), req.Username)
312
10x
    if err != nil {
313
        h.logger.Error(c.Request.Context(), "Error checking username uniqueness", err, map[string]interface{}{"username": req.Username})
314
        HandleAppError(c, contextutils.ErrInternalError)
315
        return
316
    }
317

318
10x
    if existingUser != nil {
319
3x
        span.SetAttributes(attribute.Bool("signup.username_exists", true))
320
3x
        HandleAppError(c, contextutils.ErrRecordExists)
321
3x
        return
322
3x
    }
323

324
    // Check if email already exists
325
7x
    existingUserByEmail, err := h.userService.GetUserByEmail(c.Request.Context(), email)
326
7x
    if err != nil {
327
        h.logger.Error(c.Request.Context(), "Error checking email uniqueness", err, map[string]interface{}{"email": email})
328
        HandleAppError(c, contextutils.ErrInternalError)
329
        return
330
    }
331

332
7x
    if existingUserByEmail != nil {
333
1x
        span.SetAttributes(attribute.Bool("signup.email_exists", true))
334
1x
        HandleAppError(c, contextutils.ErrRecordExists)
335
1x
        return
336
1x
    }
337

338
    // Set default values for optional fields
339
5x
    language := "italian" // Default to first language in the list
340
5x
    if h.config != nil {
341
5x
        // Get available languages from config
342
5x
        languages := h.config.GetLanguages()
343
5x
        if len(languages) > 0 {
344
5x
            language = languages[0]
345
5x
        }
346
    }
347
5x
    if req.PreferredLanguage != nil && *req.PreferredLanguage != "" {
348
2x
        language = *req.PreferredLanguage
349
2x
    }
350

351
    // Choose canonical default level for the selected language (first level in config)
352
5x
    level := ""
353
5x
    levels := []string{}
354
5x
    if h.config != nil {
355
5x
        levels = h.config.GetLevelsForLanguage(language)
356
5x
        if len(levels) > 0 {
357
5x
            level = levels[0]
358
5x
        }
359
    }
360

361
    // If client provided a level, require it to be a canonical code for the language.
362
5x
    if req.CurrentLevel != nil && *req.CurrentLevel != "" {
363
2x
        provided := *req.CurrentLevel
364
2x
        matched := false
365
2x
        for _, l := range levels {
366
4x
            if strings.EqualFold(l, provided) {
367
2x
                level = l
368
2x
                matched = true
369
2x
                break
370
            }
371
        }
372
2x
        if !matched {
373
            HandleAppError(c, contextutils.ErrInvalidFormat)
374
            return
375
        }
376
    }
377

378
5x
    timezone := "UTC" // Default timezone
379
5x
    if req.Timezone != nil && *req.Timezone != "" {
380
2x
        timezone = *req.Timezone
381
2x
    }
382

383
    // Update span attributes with final values
384
5x
    span.SetAttributes(
385
5x
        attribute.String("signup.language", language),
386
5x
        attribute.String("signup.level", level),
387
5x
        attribute.String("signup.timezone", timezone),
388
5x
    )
389
5x

390
5x
    // Create user with email and timezone (no AI settings)
391
5x
    user, err := h.userService.CreateUserWithEmailAndTimezone(c.Request.Context(), req.Username, email, timezone, language, level)
392
5x
    if err != nil {
393
        h.logger.Error(c.Request.Context(), "Error creating user", err, map[string]interface{}{"username": req.Username, "email": email})
394
        HandleAppError(c, contextutils.WrapError(err, "failed to create user account"))
395
        return
396
    }
397

398
    // Now set the password hash
399
5x
    if err := h.userService.UpdateUserPassword(c.Request.Context(), user.ID, req.Password); err != nil {
400
        h.logger.Error(c.Request.Context(), "Error setting user password", err, map[string]interface{}{"user_id": user.ID})
401
        // Try to clean up the user we just created
402
        if deleteErr := h.userService.DeleteUser(c.Request.Context(), user.ID); deleteErr != nil {
403
            h.logger.Error(c.Request.Context(), "Error cleaning up user after password set failure", err, map[string]interface{}{"user_id": user.ID, "error": deleteErr.Error()})
404
        }
405
        HandleAppError(c, contextutils.WrapError(err, "failed to create user account"))
406
        return
407
    }
408

409
    // Update span attributes with created user info
410
5x
    span.SetAttributes(
411
5x
        attribute.Int("user.id", user.ID),
412
5x
        attribute.String("user.username", user.Username),
413
5x
        attribute.String("user.email", email),
414
5x
    )
415
5x

416
5x
    h.logger.Info(c.Request.Context(), "Successfully created user", map[string]interface{}{"username": req.Username, "user_id": user.ID})
417
5x

418
5x
    // Return success response (no session created, no auto-login)
419
5x
    c.JSON(http.StatusCreated, api.SuccessResponse{
420
5x
        Success: true,
421
5x
        Message: stringPtr("Account created successfully. Please log in."),
422
5x
    })
423
}
424

425
// GoogleLogin initiates Google OAuth flow
426
19x
func (h *AuthHandler) GoogleLogin(c *gin.Context) {
427
19x
    _, span := observability.TraceHandlerFunction(c.Request.Context(), "google_login")
428
19x
    defer observability.FinishSpan(span, nil)
429
19x

430
19x
    // Generate a state parameter for security
431
19x
    state := generateRandomState()
432
19x

433
19x
    // Get the redirect URI from query parameters
434
19x
    redirectURI := c.Query("redirect_uri")
435
19x

436
19x
    // Set span attributes
437
19x
    span.SetAttributes(
438
19x
        attribute.String("oauth.provider", "google"),
439
19x
        attribute.String("oauth.state", state),
440
19x
        attribute.String("oauth.redirect_uri", redirectURI),
441
19x
    )
442
19x

443
19x
    // Store state and redirect URI in session for verification
444
19x
    session := sessions.Default(c)
445
19x
    session.Set("oauth_state", state)
446
19x
    if redirectURI != "" {
447
1x
        session.Set("oauth_redirect_uri", redirectURI)
448
1x
    }
449
19x
    if err := session.Save(); err != nil {
450
        HandleAppError(c, contextutils.WrapError(err, "failed to save session"))
451
        return
452
    }
453

454
    // Generate Google OAuth URL
455
19x
    authURL := h.oauthService.GetGoogleAuthURL(c.Request.Context(), state)
456
19x

457
19x
    c.JSON(http.StatusOK, gin.H{
458
19x
        "auth_url": authURL,
459
19x
    })
460
}
461

462
// GoogleCallback handles the OAuth callback from Google
463
19x
func (h *AuthHandler) GoogleCallback(c *gin.Context) {
464
19x
    _, span := observability.TraceHandlerFunction(c.Request.Context(), "google_callback")
465
19x
    defer observability.FinishSpan(span, nil)
466
19x

467
19x
    // Get the authorization code and state from query parameters
468
19x
    code := c.Query("code")
469
19x
    state := c.Query("state")
470
19x

471
19x
    // Set span attributes
472
19x
    span.SetAttributes(
473
19x
        attribute.String("oauth.provider", "google"),
474
19x
        attribute.Bool("oauth.code_provided", code != ""),
475
19x
        attribute.String("oauth.state", state),
476
19x
    )
477
19x

478
19x
    h.logger.Info(c.Request.Context(), "Google OAuth callback received", map[string]interface{}{"code": code, "state": state})
479
19x

480
19x
    if code == "" {
481
4x
        HandleAppError(c, contextutils.ErrMissingRequired)
482
4x
        return
483
4x
    }
484

485
    // Verify state parameter for OAuth security (CSRF protection)
486
15x
    session := sessions.Default(c)
487
15x
    storedState := session.Get("oauth_state")
488
15x

489
15x
    h.logger.Info(c.Request.Context(), "OAuth state verification", map[string]interface{}{"stored_state": storedState, "received_state": state})
490
15x

491
15x
    // Enforce strict state verification for security
492
15x
    if storedState == nil {
493
5x
        h.logger.Error(c.Request.Context(), "No OAuth state found in session - possible CSRF attack or session issue", nil, map[string]interface{}{"state": state})
494
5x
        span.SetAttributes(attribute.Bool("oauth.state_valid", false))
495
5x
        HandleAppError(c, contextutils.ErrOAuthStateMismatch)
496
5x
        return
497
5x
    }
498

499
10x
    if storedState.(string) != state {
500
4x
        h.logger.Error(c.Request.Context(), "OAuth state mismatch - possible CSRF attack", nil, map[string]interface{}{"stored_state": storedState.(string), "received_state": state})
501
4x
        span.SetAttributes(attribute.Bool("oauth.state_valid", false))
502
4x
        HandleAppError(c, contextutils.ErrOAuthStateMismatch)
503
4x
        return
504
4x
    }
505

506
3x
    span.SetAttributes(attribute.Bool("oauth.state_valid", true))
507
3x
    h.logger.Info(c.Request.Context(), "OAuth state verification successful")
508
3x

509
3x
    // Check if user is already authenticated (prevent duplicate callbacks)
510
3x
    existingUserID := session.Get(middleware.UserIDKey)
511
3x
    if existingUserID != nil {
512
        h.logger.Info(c.Request.Context(), "User already authenticated during OAuth callback", map[string]interface{}{
513
            "user_id": existingUserID.(int),
514
        })
515
        span.SetAttributes(attribute.Bool("oauth.duplicate_callback", true))
516

517
        // Get user information for the response
518
        user, err := h.userService.GetUserByID(c.Request.Context(), existingUserID.(int))
519
        if err != nil {
520
            h.logger.Error(c.Request.Context(), "Error getting user by ID", err, map[string]interface{}{"user_id": existingUserID.(int)})
521
            HandleAppError(c, contextutils.ErrInternalError)
522
            return
523
        }
524

525
        if user == nil {
526
            h.logger.Error(c.Request.Context(), "User not found", nil, map[string]interface{}{"user_id": existingUserID.(int)})
527
            HandleAppError(c, contextutils.ErrInternalError)
528
            return
529
        }
530

531
        // Convert models.User to api.User with proper API key checking
532
        apiUser := convertUserToAPIWithService(c.Request.Context(), user, h.userService)
533

534
        // Return success response for already authenticated user
535
        response := api.LoginResponse{
536
            Success: boolPtr(true),
537
            Message: stringPtr("Already authenticated"),
538
            User:    &apiUser,
539
        }
540
        c.JSON(http.StatusOK, response)
541
        return
542
    }
543

544
    // Get the stored redirect URI from session
545
3x
    storedRedirectURI := session.Get("oauth_redirect_uri")
546
3x
    var redirectURI string
547
3x
    if storedRedirectURI != nil {
548
1x
        redirectURI = storedRedirectURI.(string)
549
1x
    }
550

551
    // Clear the state and redirect URI from session
552
3x
    session.Delete("oauth_state")
553
3x
    session.Delete("oauth_redirect_uri")
554
3x
    if err := session.Save(); err != nil {
555
        h.logger.Error(c.Request.Context(), "Failed to save session", err, map[string]interface{}{"error": err.Error()})
556
        HandleAppError(c, contextutils.WrapError(err, "failed to save session"))
557
        return
558
    }
559

560
    // Authenticate user with Google OAuth
561
3x
    user, err := h.oauthService.AuthenticateGoogleUser(c.Request.Context(), code, h.userService)
562
3x
    if err != nil {
563
1x
        h.logger.Error(c.Request.Context(), "Google OAuth authentication failed", err, map[string]interface{}{"error": err.Error()})
564
1x

565
1x
        // Check if this is a signup disabled error (structured)
566
1x
        if errors.Is(err, services.ErrSignupsDisabled) {
567
1x
            span.SetAttributes(attribute.Bool("oauth.signups_disabled", true))
568
1x
            HandleAppError(c, contextutils.ErrForbidden)
569
1x
            return
570
1x
        }
571

572
        // Provide better error messages to the frontend using structured error checking
573
        errorMessage := "Authentication failed"
574
        if errors.Is(err, services.ErrOAuthCodeAlreadyUsed) {
575
            errorMessage = "This authentication link has already been used. Please try signing in again."
576
        } else if errors.Is(err, services.ErrOAuthClientConfig) {
577
            errorMessage = "OAuth configuration error. Please contact support."
578
        } else if errors.Is(err, services.ErrOAuthInvalidRequest) {
579
            errorMessage = "Invalid authentication request. Please try again."
580
        } else if errors.Is(err, services.ErrOAuthUnauthorized) {
581
            errorMessage = "OAuth client is not authorized. Please contact support."
582
        } else if errors.Is(err, services.ErrOAuthUnsupportedGrant) {
583
            errorMessage = "Unsupported OAuth grant type. Please contact support."
584
        }
585

586
        HandleAppError(c, contextutils.WrapError(err, errorMessage))
587
        return
588
    }
589

590
    // Update span attributes with user info
591
2x
    span.SetAttributes(
592
2x
        attribute.Int("user.id", user.ID),
593
2x
        attribute.String("user.username", user.Username),
594
2x
        attribute.Bool("user.email_provided", user.Email.Valid),
595
2x
        attribute.String("user.language", user.PreferredLanguage.String),
596
2x
        attribute.String("user.level", user.CurrentLevel.String),
597
2x
        attribute.Bool("user.is_new", user.CreatedAt.After(time.Now().Add(-5*time.Minute))), // Rough check if user was just created
598
2x
    )
599
2x

600
2x
    // Update last active
601
2x
    if err := h.userService.UpdateLastActive(c.Request.Context(), user.ID); err != nil {
602
        h.logger.Warn(c.Request.Context(), "Failed to update last active for user", map[string]interface{}{"user_id": user.ID, "error": err.Error()})
603
    }
604

605
    // Create session
606
2x
    session.Set(middleware.UserIDKey, user.ID)
607
2x
    session.Set(middleware.UsernameKey, user.Username)
608
2x

609
2x
    h.logger.Info(c.Request.Context(), "Setting session for user", map[string]interface{}{"user_id": user.ID, "username": user.Username})
610
2x

611
2x
    if err := session.Save(); err != nil {
612
        h.logger.Error(c.Request.Context(), "Failed to save session", err, map[string]interface{}{"error": err.Error()})
613
        HandleAppError(c, contextutils.WrapError(err, "failed to create session"))
614
        return
615
    }
616

617
    // Convert models.User to api.User with proper API key checking
618
2x
    apiUser := convertUserToAPIWithService(c.Request.Context(), user, h.userService)
619
2x

620
2x
    h.logger.Info(c.Request.Context(), "Google OAuth successful for user", map[string]interface{}{"username": user.Username, "user_id": user.ID})
621
2x

622
2x
    // Return user info with redirect URI if available
623
2x
    response := api.LoginResponse{
624
2x
        Success: boolPtr(true),
625
2x
        Message: stringPtr("Google authentication successful"),
626
2x
        User:    &apiUser,
627
2x
    }
628
2x

629
2x
    // Add redirect URI to response if it was stored
630
2x
    if redirectURI != "" {
631
1x
        response.RedirectUri = &redirectURI
632
1x
    }
633

634
2x
    c.JSON(http.StatusOK, response)
635
}
636

637
// generateRandomState generates a cryptographically secure random state parameter for OAuth security
638
1122x
func generateRandomState() string {
639
1122x
    const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
640
1122x
    b := make([]byte, 32)
641
1122x

642
1122x
    // Use crypto/rand for cryptographically secure random generation
643
1122x
    for i := range b {
644
35904x
        // Generate a random byte and map it to charset
645
35904x
        randomByte := make([]byte, 1)
646
35904x
        if _, err := rand.Read(randomByte); err != nil {
647
            // If crypto/rand fails, we have a serious system issue - don't fallback to weaker randomness
648
            panic("Cryptographic random number generation failed: " + err.Error())
649
        }
650
35904x
        b[i] = charset[randomByte[0]%byte(len(charset))]
651
    }
652
1122x
    return string(b)
653
}
654

655
// SignupStatus returns whether signups are enabled or disabled
656
4x
func (h *AuthHandler) SignupStatus(c *gin.Context) {
657
4x
    _, span := observability.TraceHandlerFunction(c.Request.Context(), "signup_status")
658
4x
    defer observability.FinishSpan(span, nil)
659
4x

660
4x
    signupsDisabled := false
661
4x
    oauthWhitelistEnabled := false
662
4x
    var allowedDomains []string
663
4x
    var allowedEmails []string
664
4x

665
4x
    if h.config != nil {
666
4x
        signupsDisabled = h.config.IsSignupDisabled()
667
4x
        if h.config.System != nil {
668
3x
            oauthWhitelistEnabled = len(h.config.System.Auth.AllowedDomains) > 0 || len(h.config.System.Auth.AllowedEmails) > 0
669
3x
            allowedDomains = h.config.System.Auth.AllowedDomains
670
3x
            allowedEmails = h.config.System.Auth.AllowedEmails
671
3x
        }
672
    }
673

674
4x
    span.SetAttributes(
675
4x
        attribute.Bool("auth.signups_disabled", signupsDisabled),
676
4x
        attribute.Bool("auth.config_available", h.config != nil),
677
4x
        attribute.Bool("auth.oauth_whitelist_enabled", oauthWhitelistEnabled),
678
4x
    )
679
4x

680
4x
    c.JSON(http.StatusOK, gin.H{
681
4x
        "signups_disabled":        signupsDisabled,
682
4x
        "oauth_whitelist_enabled": oauthWhitelistEnabled,
683
4x
        "allowed_domains":         allowedDomains,
684
4x
        "allowed_emails":          allowedEmails,
685
4x
    })
686
}
687


			
quizapp internal handlers worker_admin_handler.go
86.4%
Statements
19/22
1
package handlers
2

3
import (
4
    "context"
5
    "errors"
6

7
    "quizapp/internal/middleware"
8

9
    "github.com/gin-contrib/sessions"
10
    "github.com/gin-gonic/gin"
11
)
12

13
var (
14
    // ErrUnauthenticated indicates no current user could be determined
15
    ErrUnauthenticated = errors.New("user not authenticated")
16
    // ErrInvalidUserID indicates the stored user identifier is malformed
17
    ErrInvalidUserID = errors.New("invalid user id")
18
    // ErrForbidden indicates the user lacks permissions for the operation
19
    ErrForbidden = errors.New("forbidden")
20
)
21

22
// GetCurrentUserID returns the current authenticated user's ID.
23
// It first checks the Gin context (set by RequireAuth/RequireAdmin),
24
// then falls back to the session store. Returns an error if unauthenticated
25
// or if the stored value is invalid.
26
13x
func GetCurrentUserID(c *gin.Context) (int, error) {
27
13x
    if rawID, exists := c.Get(middleware.UserIDKey); exists {
28
9x
        if id, ok := rawID.(int); ok {
29
7x
            return id, nil
30
7x
        }
31
1x
        return 0, ErrInvalidUserID
32
    }
33

34
    // Fallback to session lookup if context not populated
35
2x
    session := sessions.Default(c)
36
2x
    userID := session.Get(middleware.UserIDKey)
37
2x
    if userID == nil {
38
1x
        return 0, ErrUnauthenticated
39
1x
    }
40
1x
    id, ok := userID.(int)
41
1x
    if !ok {
42
        return 0, ErrInvalidUserID
43
    }
44
1x
    return id, nil
45
}
46

47
// authzAdminChecker is the minimal capability needed from user service for admin checks.
48
// Any concrete user service that implements IsAdmin satisfies this interface.
49
type authzAdminChecker interface {
50
    IsAdmin(ctx context.Context, userID int) (bool, error)
51
}
52

53
// RequireSelfOrAdmin permits the action if the current user is the target user
54
// or has admin privileges. Returns ErrForbidden when neither condition is met.
55
9x
func RequireSelfOrAdmin(ctx context.Context, svc authzAdminChecker, currentID, targetID int) error {
56
9x
    if currentID == 0 {
57
        return ErrUnauthenticated
58
    }
59
9x
    if currentID == targetID {
60
4x
        return nil
61
4x
    }
62

63
5x
    isAdmin, err := svc.IsAdmin(ctx, currentID)
64
5x
    if err != nil {
65
        return err
66
    }
67
5x
    if !isAdmin {
68
1x
        return ErrForbidden
69
1x
    }
70
3x
    return nil
71
}
72


			
quizapp internal handlers worker_admin_handler.go
89.1%
Statements
278/312
1
package handlers
2

3
import (
4
    "context"
5
    "encoding/json"
6
    "time"
7

8
    "quizapp/internal/api"
9
    "quizapp/internal/models"
10
    "quizapp/internal/services"
11
    contextutils "quizapp/internal/utils"
12

13
    openapi_types "github.com/oapi-codegen/runtime/types"
14
)
15

16
// Helper functions for pointer conversion
17
543x
func stringPtr(s string) *string {
18
543x
    return &s
19
543x
}
20

21
196x
func boolPtr(b bool) *bool {
22
196x
    return &b
23
196x
}
24

25
360x
func int64Ptr(i int) *int64 {
26
360x
    i64 := int64(i)
27
360x
    return &i64
28
360x
}
29

30
173x
func float32Ptr(f float32) *float32 {
31
173x
    return &f
32
173x
}
33

34
224x
func intPtr(i int) *int {
35
224x
    return &i
36
224x
}
37

38
56x
func int64FromUint(u uint) *int64 {
39
56x
    i64 := int64(u)
40
56x
    return &i64
41
56x
}
42

43
48x
func timePtr(t time.Time) *time.Time {
44
48x
    return &t
45
48x
}
46

47
// formatTimePtr formats a time.Time into an RFC3339 string pointer
48
197x
func formatTimePtr(t time.Time) *string {
49
197x
    s := t.In(time.UTC).Format(time.RFC3339)
50
197x
    return &s
51
197x
}
52

53
// formatTimePointer converts a *time.Time to *string (RFC3339) or nil
54
197x
func formatTimePointer(tp *time.Time) *string {
55
197x
    if tp == nil {
56
18x
        return nil
57
18x
    }
58
179x
    s := tp.In(time.UTC).Format(time.RFC3339)
59
179x
    return &s
60
}
61

62
// formatTime formats a time.Time into an RFC3339 string
63
147x
func formatTime(t time.Time) string {
64
147x
    return t.In(time.UTC).Format(time.RFC3339)
65
147x
}
66

67
// Convert models.AuthAPIKey to api.APIKeySummary
68
2x
func convertAuthAPIKeyToAPI(key *models.AuthAPIKey) api.APIKeySummary {
69
2x
    apiKey := api.APIKeySummary{}
70
2x

71
2x
    // Scalars
72
2x
    if key.ID != 0 {
73
2x
        apiKey.Id = intPtr(key.ID)
74
2x
    }
75
2x
    if key.KeyName != "" {
76
2x
        apiKey.KeyName = stringPtr(key.KeyName)
77
2x
    }
78
2x
    if key.KeyPrefix != "" {
79
2x
        apiKey.KeyPrefix = stringPtr(key.KeyPrefix)
80
2x
    }
81
2x
    if key.PermissionLevel != "" {
82
2x
        pl := api.APIKeySummaryPermissionLevel(key.PermissionLevel)
83
2x
        apiKey.PermissionLevel = &pl
84
2x
    }
85

86
    // Times
87
2x
    if !key.CreatedAt.IsZero() {
88
2x
        t := key.CreatedAt
89
2x
        apiKey.CreatedAt = &t
90
2x
    }
91
2x
    if !key.UpdatedAt.IsZero() {
92
2x
        t := key.UpdatedAt
93
2x
        apiKey.UpdatedAt = &t
94
2x
    }
95
2x
    if key.LastUsedAt.Valid {
96
1x
        t := key.LastUsedAt.Time
97
1x
        apiKey.LastUsedAt = &t
98
1x
    } else {
99
1x
        // Leave nil to represent null
100
1x
        apiKey.LastUsedAt = nil
101
1x
    }
102

103
2x
    return apiKey
104
}
105

106
// Convert slice of models.AuthAPIKey to []api.APIKeySummary
107
func convertAuthAPIKeysToAPI(keys []models.AuthAPIKey) []api.APIKeySummary {
108
    if len(keys) == 0 {
109
        return []api.APIKeySummary{}
110
    }
111
    out := make([]api.APIKeySummary, 0, len(keys))
112
    for i := range keys {
113
        out = append(out, convertAuthAPIKeyToAPI(&keys[i]))
114
    }
115
    return out
116
}
117

118
// Convert models.User to api.User
119
189x
func convertUserToAPI(user *models.User) api.User {
120
189x
    apiUser := api.User{
121
189x
        Id:       int64Ptr(user.ID),
122
189x
        Username: stringPtr(user.Username),
123
189x
    }
124
189x

125
189x
    if !user.CreatedAt.IsZero() {
126
179x
        apiUser.CreatedAt = formatTimePtr(user.CreatedAt)
127
179x
    }
128

129
189x
    if user.LastActive.Valid {
130
179x
        apiUser.LastActive = formatTimePointer(&user.LastActive.Time)
131
179x
    }
132

133
189x
    if user.Email.Valid {
134
90x
        s := user.Email.String
135
90x
        apiUser.Email = &s
136
90x
    }
137

138
189x
    if user.Timezone.Valid {
139
179x
        s := user.Timezone.String
140
179x
        apiUser.Timezone = &s
141
179x
    }
142

143
189x
    if user.PreferredLanguage.Valid {
144
187x
        s := user.PreferredLanguage.String
145
187x
        apiUser.PreferredLanguage = &s
146
187x
    }
147

148
189x
    if user.CurrentLevel.Valid {
149
187x
        s := user.CurrentLevel.String
150
187x
        apiUser.CurrentLevel = &s
151
187x
    }
152

153
189x
    if user.AIProvider.Valid {
154
86x
        s := user.AIProvider.String
155
86x
        apiUser.AiProvider = &s
156
86x
    }
157

158
189x
    if user.AIModel.Valid {
159
86x
        s := user.AIModel.String
160
86x
        apiUser.AiModel = &s
161
86x
    }
162

163
189x
    if user.WordOfDayEmailEnabled.Valid {
164
179x
        enabled := user.WordOfDayEmailEnabled.Bool
165
179x
        apiUser.WordOfDayEmailEnabled = &enabled
166
179x
    }
167

168
    // Always set ai_enabled as a boolean (never null)
169
189x
    aiEnabled := user.AIEnabled.Valid && user.AIEnabled.Bool
170
189x
    apiUser.AiEnabled = &aiEnabled
171
189x

172
189x
    // For backwards compatibility, we'll set has_api_key to false here
173
189x
    // The proper check should be done using convertUserToAPIWithService
174
189x
    hasAPIKey := false
175
189x
    apiUser.HasApiKey = &hasAPIKey
176
189x

177
189x
    // Include user roles if they exist
178
189x
    if len(user.Roles) > 0 {
179
1x
        apiRoles := make([]api.Role, len(user.Roles))
180
1x
        for i, role := range user.Roles {
181
1x
            apiRoles[i] = api.Role{
182
1x
                Id:          int64(role.ID),
183
1x
                Name:        role.Name,
184
1x
                Description: role.Description,
185
1x
                CreatedAt:   formatTime(role.CreatedAt),
186
1x
                UpdatedAt:   formatTime(role.UpdatedAt),
187
1x
            }
188
1x
        }
189
1x
        apiUser.Roles = &apiRoles
190
    }
191

192
189x
    return apiUser
193
}
194

195
// convertUserToAPIWithService converts a models.User to api.User with proper API key checking
196
189x
func convertUserToAPIWithService(ctx context.Context, user *models.User, userService services.UserServiceInterface) api.User {
197
189x
    apiUser := convertUserToAPI(user)
198
189x

199
189x
    // Check if user has a valid API key for their current provider using the new table
200
189x
    hasAPIKey := false
201
189x
    if user.AIProvider.Valid && user.AIProvider.String != "" {
202
86x
        // Use the new per-provider API key system instead of the old user.AIAPIKey field
203
86x
        if userService != nil {
204
86x
            savedKey, err := userService.GetUserAPIKey(ctx, user.ID, user.AIProvider.String)
205
86x
            if err == nil && savedKey != "" {
206
                // API key is available but not exposed in the API response for security
207
                hasAPIKey = true
208
            }
209
        }
210
    }
211
    // If user doesn't have an AI provider set, hasAPIKey remains false (default)
212
189x
    apiUser.HasApiKey = &hasAPIKey
213
189x

214
189x
    return apiUser
215
}
216

217
// Convert models.Question to api.Question
218
145x
func convertQuestionToAPI(question *models.Question) api.Question {
219
145x
    apiQuestion := api.Question{
220
145x
        Id:              int64Ptr(question.ID),
221
145x
        DifficultyScore: float32Ptr(float32(question.DifficultyScore)),
222
145x
        CorrectAnswer:   intPtr(question.CorrectAnswer),
223
145x
        // UsageCount removed; use total_responses instead
224
145x
    }
225
145x

226
145x
    if !question.CreatedAt.IsZero() {
227
145x
        v := formatTime(question.CreatedAt)
228
145x
        apiQuestion.CreatedAt = &v
229
145x
    }
230

231
145x
    if question.Type != "" {
232
145x
        qType := api.QuestionType(question.Type)
233
145x
        apiQuestion.Type = &qType
234
145x
    }
235

236
145x
    if question.Language != "" {
237
145x
        lang := api.Language(question.Language)
238
145x
        apiQuestion.Language = &lang
239
145x
    }
240

241
145x
    if question.Level != "" {
242
145x
        level := api.Level(question.Level)
243
145x
        apiQuestion.Level = &level
244
145x
    }
245

246
145x
    if question.Explanation != "" {
247
145x
        apiQuestion.Explanation = &question.Explanation
248
145x
    }
249

250
145x
    if question.Status != "" {
251
145x
        status := api.QuestionStatus(question.Status)
252
145x
        apiQuestion.Status = &status
253
145x
    }
254

255
    // Convert content map to api.QuestionContent
256
145x
    if question.Content != nil {
257
145x
        content := &api.QuestionContent{}
258
145x

259
145x
        if q, ok := question.Content["question"].(string); ok {
260
144x
            content.Question = q
261
144x
        }
262
145x
        if hint, ok := question.Content["hint"].(string); ok {
263
            content.Hint = &hint
264
        }
265
145x
        if passage, ok := question.Content["passage"].(string); ok {
266
            content.Passage = &passage
267
        }
268
145x
        if sentence, ok := question.Content["sentence"].(string); ok {
269
            content.Sentence = &sentence
270
        }
271
145x
        if opts, ok := question.Content["options"].([]interface{}); ok {
272
144x
            var options []string
273
144x
            for _, opt := range opts {
274
576x
                if o, ok := opt.(string); ok {
275
576x
                    options = append(options, o)
276
576x
                }
277
            }
278
144x
            if len(options) > 0 {
279
144x
                content.Options = options
280
144x
            }
281
        }
282
145x
        apiQuestion.Content = content
283
    }
284

285
    // Add variety elements to the API response
286
145x
    if question.TopicCategory != "" {
287
142x
        apiQuestion.TopicCategory = &question.TopicCategory
288
142x
    }
289
145x
    if question.GrammarFocus != "" {
290
141x
        apiQuestion.GrammarFocus = &question.GrammarFocus
291
141x
    }
292
145x
    if question.VocabularyDomain != "" {
293
141x
        apiQuestion.VocabularyDomain = &question.VocabularyDomain
294
141x
    }
295
145x
    if question.Scenario != "" {
296
141x
        apiQuestion.Scenario = &question.Scenario
297
141x
    }
298
145x
    if question.StyleModifier != "" {
299
141x
        apiQuestion.StyleModifier = &question.StyleModifier
300
141x
    }
301
145x
    if question.DifficultyModifier != "" {
302
141x
        apiQuestion.DifficultyModifier = &question.DifficultyModifier
303
141x
    }
304
145x
    if question.TimeContext != "" {
305
141x
        apiQuestion.TimeContext = &question.TimeContext
306
141x
    }
307

308
145x
    return apiQuestion
309
}
310

311
// Convert services.QuestionWithStats to a JSON-compatible map using generated
312
// api.Question for fields, and include any additional fields the frontend
313
// expects (e.g., report_reasons) that are not present on the generated type.
314
1x
func convertQuestionWithStatsToAPIMap(q *services.QuestionWithStats) map[string]interface{} {
315
1x
    apiQ := api.Question{}
316
1x
    if q != nil && q.Question != nil {
317
1x
        apiQ = convertQuestionToAPI(q.Question)
318
1x
    }
319

320
    // Attach stats
321
1x
    if q != nil {
322
1x
        apiQ.CorrectCount = intPtr(q.CorrectCount)
323
1x
        apiQ.IncorrectCount = intPtr(q.IncorrectCount)
324
1x
        apiQ.TotalResponses = intPtr(q.TotalResponses)
325
1x
        apiQ.UserCount = intPtr(q.UserCount)
326
1x
        if q.Reporters != "" {
327
1x
            apiQ.Reporters = &q.Reporters
328
1x
        }
329
        // ConfidenceLevel is optional
330
1x
        if q.ConfidenceLevel != nil {
331
            apiQ.ConfidenceLevel = q.ConfidenceLevel
332
        }
333
    }
334

335
    // Marshal to generic map so we can add fields not present in api.Question
336
1x
    m := map[string]interface{}{}
337
1x
    if b, err := json.Marshal(apiQ); err == nil {
338
1x
        _ = json.Unmarshal(b, &m)
339
1x
    }
340

341
    // Add report_reasons if available on the service struct
342
1x
    if q != nil && q.ReportReasons != "" {
343
1x
        m["report_reasons"] = q.ReportReasons
344
1x
    }
345

346
1x
    return m
347
}
348

349
// Convert models.UserProgress to api.UserProgress
350
18x
func convertUserProgressToAPI(ctx context.Context, progress *models.UserProgress, userID int, userLookup func(context.Context, int) (*models.User, error)) api.UserProgress {
351
18x
    apiProgress := api.UserProgress{
352
18x
        TotalQuestions: intPtr(progress.TotalQuestions),
353
18x
        CorrectAnswers: intPtr(progress.CorrectAnswers),
354
18x
        AccuracyRate:   float32Ptr(float32(progress.AccuracyRate / 100.0)),
355
18x
    }
356
18x

357
18x
    if progress.CurrentLevel != "" {
358
18x
        level := api.Level(progress.CurrentLevel)
359
18x
        apiProgress.CurrentLevel = &level
360
18x
    }
361

362
18x
    if progress.SuggestedLevel != "" {
363
        level := api.Level(progress.SuggestedLevel)
364
        apiProgress.SuggestedLevel = &level
365
    }
366

367
18x
    if progress.WeakAreas != nil {
368
4x
        apiProgress.WeakAreas = &progress.WeakAreas
369
4x
    }
370

371
    // Convert performance metrics
372
18x
    if progress.PerformanceByTopic != nil {
373
18x
        perfMap := make(map[string]api.PerformanceMetrics)
374
18x
        for topic, metrics := range progress.PerformanceByTopic {
375
10x
            if metrics != nil {
376
10x
                perfMap[topic] = api.PerformanceMetrics{
377
10x
                    TotalAttempts:         intPtr(metrics.TotalAttempts),
378
10x
                    CorrectAttempts:       intPtr(metrics.CorrectAttempts),
379
10x
                    AverageResponseTimeMs: float32Ptr(float32(metrics.AverageResponseTimeMs)),
380
10x
                    LastUpdated: func() *string {
381
10x
                        if metrics.LastUpdated.IsZero() {
382
                            return nil
383
                        }
384
10x
                        s, _, err := contextutils.FormatTimeInUserTimezone(ctx, userID, metrics.LastUpdated, time.RFC3339, userLookup)
385
10x
                        if err != nil || s == "" {
386
                            tmp := metrics.LastUpdated.In(time.UTC).Format(time.RFC3339)
387
                            return &tmp
388
                        }
389
10x
                        return &s
390
                    }(),
391
                }
392
            }
393
        }
394
18x
        apiProgress.PerformanceByTopic = &perfMap
395
    }
396

397
    // Convert recent activity
398
18x
    if progress.RecentActivity != nil {
399
10x
        var recentActivity []api.UserResponse
400
10x
        for _, activity := range progress.RecentActivity {
401
26x
            apiActivity := api.UserResponse{
402
26x
                QuestionId: int64Ptr(activity.QuestionID),
403
26x
                IsCorrect:  &activity.IsCorrect,
404
26x
            }
405
26x
            if !activity.CreatedAt.IsZero() {
406
26x
                s, _, err := contextutils.FormatTimeInUserTimezone(ctx, userID, activity.CreatedAt, time.RFC3339, userLookup)
407
26x
                if err != nil || s == "" {
408
                    tmp := activity.CreatedAt.In(time.UTC).Format(time.RFC3339)
409
                    apiActivity.CreatedAt = &tmp
410
                } else {
411
26x
                    apiActivity.CreatedAt = &s
412
26x
                }
413
            }
414
26x
            recentActivity = append(recentActivity, apiActivity)
415
        }
416
10x
        apiProgress.RecentActivity = &recentActivity
417
    }
418

419
18x
    return apiProgress
420
}
421

422
// Convert models.DailyQuestionAssignmentWithQuestion to api.DailyQuestionWithDetails
423
140x
func convertDailyAssignmentToAPI(ctx context.Context, assignment *models.DailyQuestionAssignmentWithQuestion, userID int, userLookup func(context.Context, int) (*models.User, error)) api.DailyQuestionWithDetails {
424
140x
    var completedAt *string
425
140x
    if assignment.CompletedAt.Valid {
426
7x
        if s, _, err := contextutils.FormatTimeInUserTimezone(ctx, userID, assignment.CompletedAt.Time, time.RFC3339, userLookup); err == nil && s != "" {
427
7x
            completedAt = &s
428
7x
        } else {
429
            tmp := assignment.CompletedAt.Time.In(time.UTC).Format(time.RFC3339)
430
            completedAt = &tmp
431
        }
432
    }
433

434
140x
    apiQuestion := api.Question{}
435
140x
    if assignment.Question != nil {
436
140x
        apiQuestion = convertQuestionToAPI(assignment.Question)
437
140x
        // Override total_responses so UI 'Shown' reflects Daily-only impressions
438
140x
        if assignment.DailyShownCount > 0 {
439
140x
            apiQuestion.TotalResponses = &assignment.DailyShownCount
440
140x
        }
441
    }
442

443
    // AssignmentDate: produce date-only value (YYYY-MM-DD) using openapi_types.Date
444
140x
    ad := assignment.AssignmentDate
445
140x
    assignDate := openapi_types.Date{Time: ad}
446
140x

447
140x
    // CreatedAt in user's timezone (with error-checked fallback)
448
140x
    var createdStr string
449
140x
    if s, _, err := contextutils.FormatTimeInUserTimezone(ctx, userID, assignment.CreatedAt, time.RFC3339, userLookup); err == nil && s != "" {
450
140x
        createdStr = s
451
140x
    } else {
452
        createdStr = assignment.CreatedAt.In(time.UTC).Format(time.RFC3339)
453
    }
454

455
140x
    var submittedAt *string
456
140x
    if assignment.SubmittedAt != nil {
457
7x
        if s, _, err := contextutils.FormatTimeInUserTimezone(ctx, userID, *assignment.SubmittedAt, time.RFC3339, userLookup); err == nil && s != "" {
458
7x
            submittedAt = &s
459
7x
        } else {
460
            tmp := assignment.SubmittedAt.In(time.UTC).Format(time.RFC3339)
461
            submittedAt = &tmp
462
        }
463
    }
464

465
140x
    result := api.DailyQuestionWithDetails{
466
140x
        Id:              int64(assignment.ID),
467
140x
        UserId:          int64(assignment.UserID),
468
140x
        QuestionId:      int64(assignment.QuestionID),
469
140x
        AssignmentDate:  assignDate,
470
140x
        IsCompleted:     assignment.IsCompleted,
471
140x
        CompletedAt:     completedAt,
472
140x
        CreatedAt:       createdStr,
473
140x
        UserAnswerIndex: assignment.UserAnswerIndex,
474
140x
        SubmittedAt:     submittedAt,
475
140x
        Question:        apiQuestion,
476
140x
    }
477
140x

478
140x
    // Attach per-user stats when available
479
140x
    if assignment.DailyShownCount >= 0 {
480
140x
        shown := int64(assignment.DailyShownCount)
481
140x
        result.UserShownCount = &shown
482
140x
    }
483
140x
    if assignment.UserTotalResponses >= 0 {
484
140x
        total := int64(assignment.UserTotalResponses)
485
140x
        result.UserTotalResponses = &total
486
140x
    }
487
140x
    if assignment.UserCorrectCount >= 0 {
488
140x
        cc := int64(assignment.UserCorrectCount)
489
140x
        result.UserCorrectCount = &cc
490
140x
    }
491
140x
    if assignment.UserIncorrectCount >= 0 {
492
140x
        ic := int64(assignment.UserIncorrectCount)
493
140x
        result.UserIncorrectCount = &ic
494
140x
    }
495

496
140x
    return result
497
}
498

499
// Convert slice of assignments
500
14x
func convertDailyAssignmentsToAPI(ctx context.Context, assignments []*models.DailyQuestionAssignmentWithQuestion, userID int, userLookup func(context.Context, int) (*models.User, error)) []api.DailyQuestionWithDetails {
501
14x
    if len(assignments) == 0 {
502
        return []api.DailyQuestionWithDetails{}
503
    }
504
14x
    apiAssignments := make([]api.DailyQuestionWithDetails, len(assignments))
505
14x
    for i, a := range assignments {
506
140x
        apiAssignments[i] = convertDailyAssignmentToAPI(ctx, a, userID, userLookup)
507
140x
    }
508
14x
    return apiAssignments
509
}
510

511
// Convert models.DailyProgress to api.DailyProgress
512
4x
func convertDailyProgressToAPI(progress *models.DailyProgress) api.DailyProgress {
513
4x
    return api.DailyProgress{
514
4x
        Date:      openapi_types.Date{Time: progress.Date},
515
4x
        Completed: progress.Completed,
516
4x
        Total:     progress.Total,
517
4x
    }
518
4x
}
519

520
// Convert models.Story to api.Story
521
14x
func convertStoryToAPI(story *models.Story) api.Story {
522
14x
    apiStory := api.Story{
523
14x
        Id:       int64FromUint(story.ID),
524
14x
        UserId:   int64FromUint(story.UserID),
525
14x
        Title:    stringPtr(story.Title),
526
14x
        Language: stringPtr(story.Language),
527
14x
        Status:   (*api.StoryStatus)(stringPtr(string(story.Status))),
528
14x
    }
529
14x

530
14x
    if story.Subject != nil {
531
6x
        apiStory.Subject = story.Subject
532
6x
    }
533
14x
    if story.AuthorStyle != nil {
534
        apiStory.AuthorStyle = story.AuthorStyle
535
    }
536
14x
    if story.TimePeriod != nil {
537
        apiStory.TimePeriod = story.TimePeriod
538
    }
539
14x
    if story.Genre != nil {
540
        apiStory.Genre = story.Genre
541
    }
542
14x
    if story.Tone != nil {
543
        apiStory.Tone = story.Tone
544
    }
545
14x
    if story.CharacterNames != nil {
546
        apiStory.CharacterNames = story.CharacterNames
547
    }
548
14x
    if story.CustomInstructions != nil {
549
        apiStory.CustomInstructions = story.CustomInstructions
550
    }
551
    // Handle enum field - only set if not nil (will be omitted from JSON due to omitempty)
552
14x
    if story.SectionLengthOverride != nil {
553
        lengthOverride := api.StorySectionLengthOverride(*story.SectionLengthOverride)
554
        apiStory.SectionLengthOverride = &lengthOverride
555
    }
556

557
14x
    if !story.CreatedAt.IsZero() {
558
14x
        apiStory.CreatedAt = timePtr(story.CreatedAt)
559
14x
    }
560
14x
    if !story.UpdatedAt.IsZero() {
561
14x
        apiStory.UpdatedAt = timePtr(story.UpdatedAt)
562
14x
    }
563
14x
    if story.LastSectionGeneratedAt != nil {
564
        apiStory.LastSectionGeneratedAt = timePtr(*story.LastSectionGeneratedAt)
565
    }
566

567
14x
    return apiStory
568
}
569

570
// Convert models.StorySection to api.StorySection
571
6x
func convertStorySectionToAPI(section *models.StorySection) api.StorySection {
572
6x
    apiSection := api.StorySection{
573
6x
        Id:            int64FromUint(section.ID),
574
6x
        StoryId:       int64FromUint(section.StoryID),
575
6x
        SectionNumber: intPtr(section.SectionNumber),
576
6x
        Content:       stringPtr(section.Content),
577
6x
        LanguageLevel: stringPtr(section.LanguageLevel),
578
6x
        WordCount:     intPtr(section.WordCount),
579
6x
    }
580
6x

581
6x
    if !section.GeneratedAt.IsZero() {
582
6x
        apiSection.GeneratedAt = timePtr(section.GeneratedAt)
583
6x
    }
584

585
    // Convert time.Time to openapi_types.Date for generation_date
586
6x
    if !section.GenerationDate.IsZero() {
587
6x
        generationDate := openapi_types.Date{Time: section.GenerationDate}
588
6x
        apiSection.GenerationDate = &generationDate
589
6x
    }
590

591
6x
    return apiSection
592
}
593

594
// Convert models.StoryWithSections to api.StoryWithSections
595
6x
func convertStoryWithSectionsToAPI(story *models.StoryWithSections) api.StoryWithSections {
596
6x
    apiStory := api.StoryWithSections{
597
6x
        Id:                   int64FromUint(story.ID),
598
6x
        UserId:               int64FromUint(story.UserID),
599
6x
        Title:                stringPtr(story.Title),
600
6x
        Language:             stringPtr(story.Language),
601
6x
        Status:               (*api.StoryWithSectionsStatus)(stringPtr(string(story.Status))),
602
6x
        AutoGenerationPaused: boolPtr(story.AutoGenerationPaused),
603
6x
    }
604
6x

605
6x
    if story.Subject != nil {
606
3x
        apiStory.Subject = story.Subject
607
3x
    }
608
6x
    if story.AuthorStyle != nil {
609
2x
        apiStory.AuthorStyle = story.AuthorStyle
610
2x
    }
611
6x
    if story.TimePeriod != nil {
612
2x
        apiStory.TimePeriod = story.TimePeriod
613
2x
    }
614
6x
    if story.Genre != nil {
615
2x
        apiStory.Genre = story.Genre
616
2x
    }
617
6x
    if story.Tone != nil {
618
2x
        apiStory.Tone = story.Tone
619
2x
    }
620
6x
    if story.CharacterNames != nil {
621
2x
        apiStory.CharacterNames = story.CharacterNames
622
2x
    }
623
6x
    if story.CustomInstructions != nil {
624
2x
        apiStory.CustomInstructions = story.CustomInstructions
625
2x
    }
626
    // Handle enum field - only set if not nil (will be omitted from JSON due to omitempty)
627
6x
    if story.SectionLengthOverride != nil {
628
2x
        lengthOverride := api.StoryWithSectionsSectionLengthOverride(*story.SectionLengthOverride)
629
2x
        apiStory.SectionLengthOverride = &lengthOverride
630
2x
    }
631

632
6x
    if !story.CreatedAt.IsZero() {
633
6x
        apiStory.CreatedAt = timePtr(story.CreatedAt)
634
6x
    }
635
6x
    if !story.UpdatedAt.IsZero() {
636
6x
        apiStory.UpdatedAt = timePtr(story.UpdatedAt)
637
6x
    }
638
6x
    if story.LastSectionGeneratedAt != nil {
639
        apiStory.LastSectionGeneratedAt = timePtr(*story.LastSectionGeneratedAt)
640
    }
641

642
    // Convert sections using the section conversion function
643
6x
    if len(story.Sections) > 0 {
644
4x
        apiSections := make([]api.StorySection, len(story.Sections))
645
4x
        for i, section := range story.Sections {
646
6x
            apiSections[i] = convertStorySectionToAPI(&section)
647
6x
        }
648
4x
        apiStory.Sections = &apiSections
649
    }
650

651
6x
    return apiStory
652
}
653

654
// Convert models.StorySectionWithQuestions to api.StorySectionWithQuestions
655
1x
func convertStorySectionWithQuestionsToAPI(sectionWithQuestions *models.StorySectionWithQuestions) api.StorySectionWithQuestions {
656
1x
    apiSectionWithQuestions := api.StorySectionWithQuestions{
657
1x
        Id:            int64FromUint(sectionWithQuestions.ID),
658
1x
        StoryId:       int64FromUint(sectionWithQuestions.StoryID),
659
1x
        SectionNumber: intPtr(sectionWithQuestions.SectionNumber),
660
1x
        Content:       stringPtr(sectionWithQuestions.Content),
661
1x
        LanguageLevel: stringPtr(sectionWithQuestions.LanguageLevel),
662
1x
        WordCount:     intPtr(sectionWithQuestions.WordCount),
663
1x
    }
664
1x

665
1x
    if !sectionWithQuestions.GeneratedAt.IsZero() {
666
1x
        apiSectionWithQuestions.GeneratedAt = timePtr(sectionWithQuestions.GeneratedAt)
667
1x
    }
668

669
    // Convert time.Time to openapi_types.Date for generation_date
670
1x
    if !sectionWithQuestions.GenerationDate.IsZero() {
671
1x
        generationDate := openapi_types.Date{Time: sectionWithQuestions.GenerationDate}
672
1x
        apiSectionWithQuestions.GenerationDate = &generationDate
673
1x
    }
674

675
    // Convert questions
676
1x
    if len(sectionWithQuestions.Questions) > 0 {
677
1x
        apiQuestions := make([]api.StorySectionQuestion, len(sectionWithQuestions.Questions))
678
1x
        for i, question := range sectionWithQuestions.Questions {
679
1x
            apiQuestions[i] = api.StorySectionQuestion{
680
1x
                Id:                 int64FromUint(question.ID),
681
1x
                SectionId:          int64FromUint(question.SectionID),
682
1x
                QuestionText:       stringPtr(question.QuestionText),
683
1x
                Options:            &question.Options,
684
1x
                CorrectAnswerIndex: intPtr(question.CorrectAnswerIndex),
685
1x
                CreatedAt:          timePtr(question.CreatedAt),
686
1x
            }
687
1x
            if question.Explanation != nil {
688
1x
                apiQuestions[i].Explanation = question.Explanation
689
1x
            }
690
        }
691
1x
        apiSectionWithQuestions.Questions = &apiQuestions
692
    }
693

694
1x
    return apiSectionWithQuestions
695
}
696


			
quizapp internal handlers worker_admin_handler.go
56.9%
Statements
160/281
1
package handlers
2

3
import (
4
    "context"
5
    "net/http"
6
    "strconv"
7
    "strings"
8
    "time"
9

10
    "quizapp/internal/api"
11
    "quizapp/internal/config"
12
    "quizapp/internal/observability"
13
    "quizapp/internal/services"
14
    contextutils "quizapp/internal/utils"
15

16
    "github.com/gin-gonic/gin"
17
    "go.opentelemetry.io/otel/attribute"
18
    "go.opentelemetry.io/otel/codes"
19
    "go.opentelemetry.io/otel/trace"
20
)
21

22
// DailyQuestionHandler handles daily question-related HTTP requests
23
type DailyQuestionHandler struct {
24
    userService          services.UserServiceInterface
25
    dailyQuestionService services.DailyQuestionServiceInterface
26
    cfg                  *config.Config
27
    logger               *observability.Logger
28
}
29

30
// NewDailyQuestionHandler creates a new DailyQuestionHandler
31
func NewDailyQuestionHandler(
32
    userService services.UserServiceInterface,
33
    dailyQuestionService services.DailyQuestionServiceInterface,
34
    cfg *config.Config,
35
    logger *observability.Logger,
36
17x
) *DailyQuestionHandler {
37
17x
    return &DailyQuestionHandler{
38
17x
        userService:          userService,
39
17x
        dailyQuestionService: dailyQuestionService,
40
17x
        cfg:                  cfg,
41
17x
        logger:               logger,
42
17x
    }
43
17x
}
44

45
// ParseDateInUserTimezone parses a date string in the user's timezone
46
42x
func (h *DailyQuestionHandler) ParseDateInUserTimezone(ctx context.Context, userID int, dateStr string) (time.Time, string, error) {
47
42x
    // Delegate to shared util with injected user lookup
48
42x
    return contextutils.ParseDateInUserTimezone(ctx, userID, dateStr, h.userService.GetUserByID)
49
42x
}
50

51
// GetDailyQuestions handles GET /v1/daily/questions/{date}
52
15x
func (h *DailyQuestionHandler) GetDailyQuestions(c *gin.Context) {
53
15x
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "get_daily_questions")
54
15x
    defer observability.FinishSpan(span, nil)
55
15x

56
15x
    userID, exists := GetUserIDFromSession(c)
57
15x
    if !exists {
58
        HandleAppError(c, contextutils.ErrUnauthorized)
59
        return
60
    }
61

62
    // Parse date parameter
63
15x
    dateStr := c.Param("date")
64
15x
    if dateStr == "" {
65
        HandleAppError(c, contextutils.ErrMissingRequired)
66
        return
67
    }
68

69
    // Parse date in user's timezone
70
15x
    date, timezone, err := h.ParseDateInUserTimezone(ctx, userID, dateStr)
71
15x
    if err != nil {
72
1x
        // Check if it's an invalid date format error
73
1x
        if strings.Contains(err.Error(), "invalid date format") {
74
1x
            HandleAppError(c, contextutils.ErrInvalidFormat)
75
1x
            return
76
1x
        }
77
        HandleAppError(c, contextutils.WrapError(err, "failed to get user information"))
78
        return
79
    }
80

81
    // Add span attributes for observability
82
14x
    span.SetAttributes(
83
14x
        observability.AttributeUserID(userID),
84
14x
        attribute.String("date", dateStr),
85
14x
        attribute.String("timezone", timezone),
86
14x
    )
87
14x

88
14x
    // Get user to check current language preferences
89
14x
    user, err := h.userService.GetUserByID(ctx, userID)
90
14x
    if err != nil {
91
        h.logger.Error(ctx, "Failed to get user for language preference check", err, map[string]interface{}{
92
            "user_id": userID,
93
        })
94
        HandleAppError(c, contextutils.WrapError(err, "failed to get user information"))
95
        return
96
    }
97

98
    // Check if user has valid language and level preferences
99
14x
    if !user.PreferredLanguage.Valid || !user.CurrentLevel.Valid {
100
        HandleAppError(c, contextutils.ErrMissingRequired)
101
        return
102
    }
103

104
14x
    currentLanguage := user.PreferredLanguage.String
105
14x
    currentLevel := user.CurrentLevel.String
106
14x

107
14x
    // Get daily questions for the date
108
14x
    questions, err := h.dailyQuestionService.GetDailyQuestions(ctx, userID, date)
109
14x
    if err != nil {
110
        h.logger.Error(ctx, "Failed to get daily questions", err, map[string]interface{}{
111
            "user_id":  userID,
112
            "date":     dateStr,
113
            "timezone": timezone,
114
        })
115
        HandleAppError(c, contextutils.WrapError(err, "failed to get daily questions"))
116
        return
117
    }
118

119
    // Check if existing questions match current language preferences
120
14x
    needsRegeneration := false
121
14x
    var oldLanguage, oldLevel string
122
14x

123
14x
    if len(questions) == 0 {
124
        // No questions exist, need to generate them
125
        needsRegeneration = true
126
    } else {
127
14x
        // Check if existing questions match current preferences
128
14x
        oldLanguage = questions[0].Question.Language
129
14x
        oldLevel = questions[0].Question.Level
130
14x

131
14x
        for _, assignment := range questions {
132
140x
            if assignment.Question.Language != currentLanguage || assignment.Question.Level != currentLevel {
133
                needsRegeneration = true
134
                break
135
            }
136
        }
137
    }
138

139
    // If questions don't match current preferences, regenerate them
140
14x
    if needsRegeneration {
141
        h.logger.Info(ctx, "Regenerating daily questions due to language preference change", map[string]interface{}{
142
            "user_id":      userID,
143
            "date":         dateStr,
144
            "old_language": oldLanguage,
145
            "old_level":    oldLevel,
146
            "new_language": currentLanguage,
147
            "new_level":    currentLevel,
148
        })
149

150
        // Regenerate daily questions with current preferences
151
        err = h.dailyQuestionService.RegenerateDailyQuestions(ctx, userID, date)
152
        if err != nil {
153
            // Check if this is a "no questions available" error
154
            if contextutils.IsError(err, contextutils.ErrNoQuestionsAvailable) {
155
                h.logger.Warn(ctx, "No questions available in preferred language, keeping existing questions", map[string]interface{}{
156
                    "user_id":  userID,
157
                    "date":     dateStr,
158
                    "language": currentLanguage,
159
                    "level":    currentLevel,
160
                    "error":    err.Error(),
161
                })
162
                // Continue with existing questions rather than failing completely
163
            } else {
164
                h.logger.Error(ctx, "Failed to regenerate daily questions", err, map[string]interface{}{
165
                    "user_id": userID,
166
                    "date":    dateStr,
167
                })
168
                // Continue with existing questions rather than failing completely
169
                h.logger.Warn(ctx, "Continuing with existing questions due to regeneration failure", map[string]interface{}{
170
                    "user_id": userID,
171
                    "date":    dateStr,
172
                })
173
            }
174
        } else {
175
            // Get the regenerated questions
176
            questions, err = h.dailyQuestionService.GetDailyQuestions(ctx, userID, date)
177
            if err != nil {
178
                h.logger.Error(ctx, "Failed to get regenerated daily questions", err, map[string]interface{}{
179
                    "user_id": userID,
180
                    "date":    dateStr,
181
                })
182
                HandleAppError(c, contextutils.WrapError(err, "failed to get daily questions"))
183
                return
184
            }
185
        }
186
    }
187

188
    // Convert to API types using shared converter
189
14x
    apiQuestions := convertDailyAssignmentsToAPI(ctx, questions, userID, h.userService.GetUserByID)
190
14x

191
14x
    c.JSON(http.StatusOK, gin.H{
192
14x
        "questions": apiQuestions,
193
14x
        "date":      dateStr,
194
14x
    })
195
}
196

197
// MarkQuestionCompleted handles POST /v1/daily/questions/{date}/complete/{questionId}
198
6x
func (h *DailyQuestionHandler) MarkQuestionCompleted(c *gin.Context) {
199
6x
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "mark_daily_question_completed")
200
6x
    defer observability.FinishSpan(span, nil)
201
6x

202
6x
    userID, exists := GetUserIDFromSession(c)
203
6x
    if !exists {
204
        HandleAppError(c, contextutils.ErrUnauthorized)
205
        return
206
    }
207

208
    // Parse parameters
209
6x
    dateStr := c.Param("date")
210
6x
    questionIDStr := c.Param("questionId")
211
6x

212
6x
    if dateStr == "" || questionIDStr == "" {
213
        HandleAppError(c, contextutils.ErrMissingRequired)
214
        return
215
    }
216

217
    // Parse date in user's timezone
218
6x
    date, timezone, err := h.ParseDateInUserTimezone(ctx, userID, dateStr)
219
6x
    if err != nil {
220
        // Check if it's an invalid date format error
221
        if strings.Contains(err.Error(), "invalid date format") {
222
            HandleAppError(c, contextutils.ErrInvalidFormat)
223
            return
224
        }
225
        HandleAppError(c, contextutils.WrapError(err, "failed to get user information"))
226
        return
227
    }
228

229
6x
    questionID, err := strconv.Atoi(questionIDStr)
230
6x
    if err != nil {
231
1x
        HandleAppError(c, contextutils.ErrInvalidFormat)
232
1x
        return
233
1x
    }
234

235
    // Add span attributes for observability
236
5x
    span.SetAttributes(
237
5x
        observability.AttributeUserID(userID),
238
5x
        attribute.String("date", dateStr),
239
5x
        attribute.Int("question_id", questionID),
240
5x
        attribute.String("timezone", timezone),
241
5x
    )
242
5x

243
5x
    // Mark question as completed
244
5x
    err = h.dailyQuestionService.MarkQuestionCompleted(ctx, userID, questionID, date)
245
5x
    if err != nil {
246
        h.logger.Error(ctx, "Failed to mark daily question as completed", err, map[string]interface{}{
247
            "user_id":     userID,
248
            "question_id": questionID,
249
            "date":        dateStr,
250
            "timezone":    timezone,
251
        })
252

253
        // Check if the error indicates no assignment was found
254
        if contextutils.IsError(err, contextutils.ErrAssignmentNotFound) {
255
            HandleAppError(c, contextutils.ErrAssignmentNotFound)
256
            return
257
        }
258

259
        HandleAppError(c, contextutils.WrapError(err, "failed to mark question as completed"))
260
        return
261
    }
262

263
5x
    c.JSON(http.StatusOK, api.SuccessResponse{
264
5x
        Message: stringPtr("Question marked as completed"),
265
5x
        Success: true,
266
5x
    })
267
}
268

269
// ResetQuestionCompleted handles DELETE /v1/daily/questions/{date}/complete/{questionId}
270
3x
func (h *DailyQuestionHandler) ResetQuestionCompleted(c *gin.Context) {
271
3x
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "reset_daily_question_completed")
272
3x
    defer observability.FinishSpan(span, nil)
273
3x

274
3x
    userID, exists := GetUserIDFromSession(c)
275
3x
    if !exists {
276
        HandleAppError(c, contextutils.ErrUnauthorized)
277
        return
278
    }
279

280
    // Parse parameters
281
3x
    dateStr := c.Param("date")
282
3x
    questionIDStr := c.Param("questionId")
283
3x

284
3x
    if dateStr == "" || questionIDStr == "" {
285
        HandleAppError(c, contextutils.ErrMissingRequired)
286
        return
287
    }
288

289
    // Parse date in user's timezone
290
3x
    date, timezone, err := h.ParseDateInUserTimezone(ctx, userID, dateStr)
291
3x
    if err != nil {
292
        // Check if it's an invalid date format error
293
        if strings.Contains(err.Error(), "invalid date format") {
294
            HandleAppError(c, contextutils.ErrInvalidFormat)
295
            return
296
        }
297
        HandleAppError(c, contextutils.WrapError(err, "failed to get user information"))
298
        return
299
    }
300

301
3x
    questionID, err := strconv.Atoi(questionIDStr)
302
3x
    if err != nil {
303
        HandleAppError(c, contextutils.ErrInvalidFormat)
304
        return
305
    }
306

307
    // Add span attributes for observability
308
3x
    span.SetAttributes(
309
3x
        observability.AttributeUserID(userID),
310
3x
        attribute.String("date", dateStr),
311
3x
        attribute.Int("question_id", questionID),
312
3x
        attribute.String("timezone", timezone),
313
3x
    )
314
3x

315
3x
    // Reset question completion status
316
3x
    err = h.dailyQuestionService.ResetQuestionCompleted(ctx, userID, questionID, date)
317
3x
    if err != nil {
318
        h.logger.Error(ctx, "Failed to reset daily question completion", err, map[string]interface{}{
319
            "user_id":     userID,
320
            "question_id": questionID,
321
            "date":        dateStr,
322
            "timezone":    timezone,
323
        })
324

325
        // Check if the error indicates no assignment was found
326
        if contextutils.IsError(err, contextutils.ErrAssignmentNotFound) {
327
            HandleAppError(c, contextutils.ErrAssignmentNotFound)
328
            return
329
        }
330

331
        HandleAppError(c, contextutils.WrapError(err, "failed to reset question completion"))
332
        return
333
    }
334

335
3x
    c.JSON(http.StatusOK, api.SuccessResponse{
336
3x
        Message: stringPtr("Question completion reset"),
337
3x
        Success: true,
338
3x
    })
339
}
340

341
// GetAvailableDates handles GET /v1/daily/dates
342
1x
func (h *DailyQuestionHandler) GetAvailableDates(c *gin.Context) {
343
1x
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "get_daily_available_dates")
344
1x
    defer observability.FinishSpan(span, nil)
345
1x

346
1x
    userID, exists := GetUserIDFromSession(c)
347
1x
    if !exists {
348
        HandleAppError(c, contextutils.ErrUnauthorized)
349
        return
350
    }
351

352
    // Add span attributes for observability
353
1x
    span.SetAttributes(observability.AttributeUserID(userID))
354
1x

355
1x
    // Get available dates with assignments
356
1x
    dates, err := h.dailyQuestionService.GetAvailableDates(ctx, userID)
357
1x
    if err != nil {
358
        h.logger.Error(ctx, "Failed to get available dates", err, map[string]interface{}{
359
            "user_id": userID,
360
        })
361
        HandleAppError(c, contextutils.WrapError(err, "failed to get available dates"))
362
        return
363
    }
364

365
    // Exclude future dates based on the user's timezone (clients expect local calendar days only)
366
1x
    user, _ := h.userService.GetUserByID(ctx, userID)
367
1x
    tz := "UTC"
368
1x
    if user != nil && user.Timezone.Valid && user.Timezone.String != "" {
369
1x
        tz = user.Timezone.String
370
1x
    }
371
1x
    loc, err := time.LoadLocation(tz)
372
1x
    if err != nil {
373
        loc = time.UTC
374
    }
375
1x
    now := time.Now().In(loc)
376
1x
    today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc)
377
1x

378
1x
    // Filter out dates that are after today in the user's timezone
379
1x
    var filtered []time.Time
380
1x
    for _, d := range dates {
381
2x
        // Treat the date value as a date-only value (time component ignored)
382
2x
        if !d.After(today) {
383
2x
            filtered = append(filtered, d)
384
2x
        }
385
    }
386

387
    // Convert dates to string format for JSON response
388
1x
    dateStrings := make([]string, len(filtered))
389
1x
    for i, date := range filtered {
390
2x
        dateStrings[i] = date.Format("2006-01-02")
391
2x
    }
392

393
1x
    c.JSON(http.StatusOK, gin.H{
394
1x
        "dates": dateStrings,
395
1x
    })
396
}
397

398
// Note: Daily question assignment is now handled automatically by the worker
399
// when sending daily reminder emails. No manual assignment endpoint needed.
400

401
// GetDailyProgress handles GET /v1/daily/progress/{date}
402
4x
func (h *DailyQuestionHandler) GetDailyProgress(c *gin.Context) {
403
4x
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "get_daily_progress")
404
4x
    defer observability.FinishSpan(span, nil)
405
4x

406
4x
    userID, exists := GetUserIDFromSession(c)
407
4x
    if !exists {
408
        HandleAppError(c, contextutils.ErrUnauthorized)
409
        return
410
    }
411

412
    // Parse date parameter
413
4x
    dateStr := c.Param("date")
414
4x
    if dateStr == "" {
415
        HandleAppError(c, contextutils.ErrMissingRequired)
416
        return
417
    }
418

419
    // Parse date in user's timezone
420
4x
    date, timezone, err := h.ParseDateInUserTimezone(ctx, userID, dateStr)
421
4x
    if err != nil {
422
        // Check if it's an invalid date format error
423
        if strings.Contains(err.Error(), "invalid date format") {
424
            HandleAppError(c, contextutils.ErrInvalidFormat)
425
            return
426
        }
427
        HandleAppError(c, contextutils.WrapError(err, "failed to get user information"))
428
        return
429
    }
430

431
    // Add span attributes for observability
432
4x
    span.SetAttributes(
433
4x
        observability.AttributeUserID(userID),
434
4x
        attribute.String("date", dateStr),
435
4x
        attribute.String("timezone", timezone),
436
4x
    )
437
4x

438
4x
    // Get daily progress for the date
439
4x
    progress, err := h.dailyQuestionService.GetDailyProgress(ctx, userID, date)
440
4x
    if err != nil {
441
        h.logger.Error(ctx, "Failed to get daily progress", err, map[string]interface{}{
442
            "user_id":  userID,
443
            "date":     dateStr,
444
            "timezone": timezone,
445
        })
446
        HandleAppError(c, contextutils.WrapError(err, "failed to get daily progress"))
447
        return
448
    }
449

450
    // Convert to API type using shared converter
451
4x
    apiProgress := convertDailyProgressToAPI(progress)
452
4x

453
4x
    c.JSON(http.StatusOK, apiProgress)
454
}
455

456
// SubmitDailyQuestionAnswer handles POST /v1/daily/questions/{date}/answer/{questionId}
457
9x
func (h *DailyQuestionHandler) SubmitDailyQuestionAnswer(c *gin.Context) {
458
9x
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "submit_daily_question_answer")
459
9x
    defer observability.FinishSpan(span, nil)
460
9x

461
9x
    h.logger.Info(ctx, "SubmitDailyQuestionAnswer handler called", map[string]interface{}{
462
9x
        "method": c.Request.Method,
463
9x
        "path":   c.Request.URL.Path,
464
9x
        "params": c.Params,
465
9x
    })
466
9x

467
9x
    userID, exists := GetUserIDFromSession(c)
468
9x
    if !exists {
469
        HandleAppError(c, contextutils.ErrUnauthorized)
470
        return
471
    }
472

473
    // Parse parameters
474
9x
    dateStr := c.Param("date")
475
9x
    questionIDStr := c.Param("questionId")
476
9x

477
9x
    if dateStr == "" || questionIDStr == "" {
478
        HandleAppError(c, contextutils.ErrMissingRequired)
479
        return
480
    }
481

482
    // Parse date in user's timezone
483
9x
    date, timezone, err := h.ParseDateInUserTimezone(ctx, userID, dateStr)
484
9x
    if err != nil {
485
        // Check if it's an invalid date format error
486
        if strings.Contains(err.Error(), "invalid date format") {
487
            HandleAppError(c, contextutils.ErrInvalidFormat)
488
            return
489
        }
490
        HandleAppError(c, contextutils.WrapError(err, "failed to get user information"))
491
        return
492
    }
493

494
9x
    questionID, err := strconv.Atoi(questionIDStr)
495
9x
    if err != nil {
496
        HandleAppError(c, contextutils.ErrInvalidFormat)
497
        return
498
    }
499

500
    // Parse request body
501
9x
    var requestBody api.PostV1DailyQuestionsDateAnswerQuestionIdJSONBody
502
9x

503
9x
    h.logger.Info(ctx, "Parsing request body", map[string]interface{}{
504
9x
        "user_id":     userID,
505
9x
        "question_id": questionID,
506
9x
        "date":        dateStr,
507
9x
        "timezone":    timezone,
508
9x
    })
509
9x

510
9x
    if err := c.ShouldBindJSON(&requestBody); err != nil {
511
        h.logger.Error(ctx, "Failed to parse request body", err, map[string]interface{}{
512
            "user_id":     userID,
513
            "question_id": questionID,
514
            "date":        dateStr,
515
            "timezone":    timezone,
516
            "error":       err.Error(),
517
        })
518
        HandleAppError(c, contextutils.NewAppErrorWithCause(
519
            contextutils.ErrorCodeInvalidInput,
520
            contextutils.SeverityWarn,
521
            "Invalid request body",
522
            "",
523
            err,
524
        ))
525
        return
526
    }
527

528
9x
    h.logger.Info(ctx, "Request body parsed successfully",
529
9x
        map[string]interface{}{
530
9x
            "user_id":           userID,
531
9x
            "question_id":       questionID,
532
9x
            "date":              dateStr,
533
9x
            "timezone":          timezone,
534
9x
            "user_answer_index": requestBody.UserAnswerIndex,
535
9x
        })
536
9x

537
9x
    // Validate user answer index
538
9x
    if requestBody.UserAnswerIndex < 0 {
539
        h.logger.Warn(ctx, "Invalid user answer index in SubmitDailyQuestionAnswer", map[string]interface{}{"user_answer_index": requestBody.UserAnswerIndex})
540
        HandleAppError(c, contextutils.ErrInvalidAnswerIndex)
541
        return
542
    }
543

544
    // Add span attributes for observability
545
9x
    span.SetAttributes(
546
9x
        observability.AttributeUserID(userID),
547
9x
        attribute.String("date", dateStr),
548
9x
        attribute.Int("question_id", questionID),
549
9x
        attribute.String("timezone", timezone),
550
9x
        attribute.Int("user_answer_index", requestBody.UserAnswerIndex),
551
9x
    )
552
9x

553
9x
    // Submit the answer
554
9x
    response, err := h.dailyQuestionService.SubmitDailyQuestionAnswer(
555
9x
        ctx,
556
9x
        userID,
557
9x
        questionID,
558
9x
        date,
559
9x
        requestBody.UserAnswerIndex,
560
9x
    )
561
9x
    if err != nil {
562
        h.logger.Error(ctx, "Failed to submit daily question answer", err, map[string]interface{}{
563
            "user_id":           userID,
564
            "question_id":       questionID,
565
            "date":              dateStr,
566
            "timezone":          timezone,
567
            "user_answer_index": requestBody.UserAnswerIndex,
568
        })
569

570
        // Check for specific error types
571
        if contextutils.IsError(err, contextutils.ErrQuestionAlreadyAnswered) {
572
            HandleAppError(c, contextutils.ErrQuestionAlreadyAnswered)
573
            return
574
        }
575
        if contextutils.IsError(err, contextutils.ErrAssignmentNotFound) {
576
            HandleAppError(c, contextutils.ErrAssignmentNotFound)
577
            return
578
        }
579
        if contextutils.IsError(err, contextutils.ErrInvalidAnswerIndex) {
580
            HandleAppError(c, contextutils.ErrInvalidAnswerIndex)
581
            return
582
        }
583

584
        HandleAppError(c, contextutils.WrapError(err, "failed to submit answer"))
585
        return
586
    }
587

588
    // Add completion status to response
589
9x
    responseWithCompletion := gin.H{
590
9x
        "user_answer_index":    response.UserAnswerIndex,
591
9x
        "user_answer":          response.UserAnswer,
592
9x
        "is_correct":           response.IsCorrect,
593
9x
        "correct_answer_index": response.CorrectAnswerIndex,
594
9x
        "explanation":          response.Explanation,
595
9x
        "is_completed":         true,
596
9x
    }
597
9x

598
9x
    c.JSON(http.StatusOK, responseWithCompletion)
599
}
600

601
// GetQuestionHistory handles GET /v1/daily/questions/{questionId}/history
602
7x
func (h *DailyQuestionHandler) GetQuestionHistory(c *gin.Context) {
603
7x
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "get_question_history")
604
7x
    defer observability.FinishSpan(span, nil)
605
7x

606
7x
    userID, exists := GetUserIDFromSession(c)
607
7x
    if !exists {
608
        HandleAppError(c, contextutils.ErrUnauthorized)
609
        return
610
    }
611

612
    // Parse question ID parameter
613
7x
    questionIDStr := c.Param("questionId")
614
7x
    if questionIDStr == "" {
615
        HandleAppError(c, contextutils.ErrMissingRequired)
616
        return
617
    }
618

619
7x
    questionID, err := strconv.Atoi(questionIDStr)
620
7x
    if err != nil {
621
1x
        HandleAppError(c, contextutils.ErrInvalidFormat)
622
1x
        return
623
1x
    }
624

625
    // Add span attributes for observability
626
6x
    span.SetAttributes(
627
6x
        observability.AttributeUserID(userID),
628
6x
        attribute.Int("question_id", questionID),
629
6x
    )
630
6x

631
6x
    // Get question history for the last 14 days
632
6x
    history, err := h.dailyQuestionService.GetQuestionHistory(ctx, userID, questionID, 14)
633
6x
    if err != nil {
634
        h.logger.Error(ctx, "Failed to get question history", err, map[string]interface{}{
635
            "user_id":     userID,
636
            "question_id": questionID,
637
        })
638
        HandleAppError(c, contextutils.WrapError(err, "failed to get question history"))
639
        return
640
    }
641

642
    // Determine user's timezone/location once, then filter out any future-dated assignments
643
6x
    user, _ := h.userService.GetUserByID(ctx, userID)
644
6x
    tz := "UTC"
645
6x
    if user != nil && user.Timezone.Valid && user.Timezone.String != "" {
646
4x
        tz = user.Timezone.String
647
4x
    }
648
6x
    loc, locErr := time.LoadLocation(tz)
649
6x
    if locErr != nil {
650
        loc = time.UTC
651
    }
652
6x
    now := time.Now().In(loc)
653
6x
    today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc)
654
6x

655
6x
    // Format times in user's timezone using helper, skipping future dates
656
6x
    resp := make([]map[string]interface{}, 0, len(history))
657
6x
    for _, he := range history {
658
9x
        // Skip future assignments in user's local date
659
9x
        ad := he.AssignmentDate.In(loc)
660
9x
        adDate := time.Date(ad.Year(), ad.Month(), ad.Day(), 0, 0, 0, 0, loc)
661
9x
        if adDate.After(today) {
662
1x
            continue
663
        }
664

665
        // Return assignment_date as date-only string (YYYY-MM-DD) using the stored UTC
666
        // date to avoid timezone ambiguity for clients.
667
8x
        assignDateStr := he.AssignmentDate.UTC().Format("2006-01-02")
668
8x
        span.SetAttributes(attribute.String("assignment_date.formatted_with", "date_only"))
669
8x

670
8x
        entry := map[string]interface{}{
671
8x
            "assignment_date": assignDateStr,
672
8x
            "is_completed":    he.IsCompleted,
673
8x
            "is_correct":      nil,
674
8x
            "submitted_at":    nil,
675
8x
        }
676
8x
        if he.IsCorrect != nil {
677
1x
            entry["is_correct"] = *he.IsCorrect
678
1x
        }
679
8x
        if he.SubmittedAt != nil {
680
4x
            submittedStr, _, submittedErr := contextutils.FormatTimeInUserTimezone(ctx, userID, *he.SubmittedAt, time.RFC3339, h.userService.GetUserByID)
681
4x
            if submittedErr != nil || submittedStr == "" {
682
                h.logger.Error(ctx, "Failed to format submitted_at in user's timezone", submittedErr, map[string]interface{}{
683
                    "user_id":         userID,
684
                    "question_id":     questionID,
685
                    "submitted_at_db": he.SubmittedAt,
686
                })
687
                span.RecordError(submittedErr, trace.WithStackTrace(true))
688
                span.SetStatus(codes.Error, "failed to format submitted_at")
689
                HandleAppError(c, contextutils.WrapError(submittedErr, "failed to format submitted_at"))
690
                return
691
            }
692
4x
            span.SetAttributes(attribute.String("submitted_at.formatted_with", "user_timezone"))
693
4x
            entry["submitted_at"] = submittedStr
694
        }
695
8x
        resp = append(resp, entry)
696
    }
697

698
6x
    c.JSON(http.StatusOK, gin.H{"history": resp})
699
}
700


			
quizapp internal handlers worker_admin_handler.go
64.4%
Statements
29/45
1
package handlers
2

3
import (
4
    "fmt"
5
    "net/http"
6

7
    contextutils "quizapp/internal/utils"
8

9
    "github.com/gin-gonic/gin"
10
)
11

12
// StandardizeHTTPError creates consistent HTTP error responses with structured error information
13
19x
func StandardizeHTTPError(c *gin.Context, statusCode int, message, details string) {
14
19x
    // Map HTTP status code to appropriate error code
15
19x
    var errorCode contextutils.ErrorCode
16
19x
    var severity contextutils.SeverityLevel
17
19x

18
19x
    switch statusCode {
19
9x
    case http.StatusBadRequest:
20
9x
        errorCode = contextutils.ErrorCodeInvalidInput
21
9x
        severity = contextutils.SeverityWarn
22
    case http.StatusUnauthorized:
23
        errorCode = contextutils.ErrorCodeUnauthorized
24
        severity = contextutils.SeverityWarn
25
    case http.StatusForbidden:
26
        errorCode = contextutils.ErrorCodeForbidden
27
        severity = contextutils.SeverityWarn
28
7x
    case http.StatusNotFound:
29
7x
        errorCode = contextutils.ErrorCodeRecordNotFound
30
7x
        severity = contextutils.SeverityInfo
31
    case http.StatusConflict:
32
        errorCode = contextutils.ErrorCodeRecordExists
33
        severity = contextutils.SeverityInfo
34
    case http.StatusServiceUnavailable:
35
        errorCode = contextutils.ErrorCodeServiceUnavailable
36
        severity = contextutils.SeverityError
37
3x
    default:
38
3x
        errorCode = contextutils.ErrorCodeInternalError
39
3x
        severity = contextutils.SeverityError
40
    }
41

42
    // Create an AppError with appropriate code
43
19x
    appErr := contextutils.NewAppError(
44
19x
        errorCode,
45
19x
        severity,
46
19x
        message,
47
19x
        details,
48
19x
    )
49
19x

50
19x
    // Send response with the original status code
51
19x
    c.JSON(statusCode, appErr.ToJSON())
52
}
53

54
// StandardizeAppError sends a structured error response using AppError
55
115x
func StandardizeAppError(c *gin.Context, err *contextutils.AppError) {
56
115x
    // Map error codes to HTTP status codes
57
115x
    statusCode := mapErrorCodeToHTTPStatus(err.Code)
58
115x

59
115x
    // Convert error to JSON structure
60
115x
    errorJSON := err.ToJSON()
61
115x

62
115x
    // Add retryable information based on error type
63
115x
    errorJSON["retryable"] = contextutils.IsRetryable(err)
64
115x

65
115x
    c.JSON(statusCode, errorJSON)
66
115x
}
67

68
// HandleValidationError handles input validation errors consistently
69
9x
func HandleValidationError(c *gin.Context, field string, value interface{}, reason string) {
70
9x
    appErr := contextutils.NewAppError(
71
9x
        contextutils.ErrorCodeInvalidInput,
72
9x
        contextutils.SeverityWarn,
73
9x
        fmt.Sprintf("Invalid %s", field),
74
9x
        fmt.Sprintf("Value '%v' is invalid: %s", value, reason),
75
9x
    )
76
9x

77
9x
    StandardizeAppError(c, appErr)
78
9x
}
79

80
// HandleAppError handles any AppError and sends appropriate HTTP response
81
106x
func HandleAppError(c *gin.Context, err error) {
82
106x
    if appErr, ok := err.(*contextutils.AppError); ok {
83
106x
        // Special-case: no questions available should return 202 with GeneratingResponse body
84
106x
        if appErr.Code == contextutils.ErrorCodeNoQuestionsAvailable {
85
            // 202 Accepted with generating payload (matches swagger GeneratingResponse)
86
            c.JSON(http.StatusAccepted, gin.H{
87
                "status":  "generating",
88
                "message": "No questions available. Please try again shortly.",
89
            })
90
            return
91
        }
92
106x
        StandardizeAppError(c, appErr)
93
    } else {
94
        // Fallback for non-AppError types
95
        StandardizeHTTPError(c, http.StatusInternalServerError, "Internal server error", err.Error())
96
    }
97
}
98

99
// mapErrorCodeToHTTPStatus maps AppError codes to appropriate HTTP status codes
100
115x
func mapErrorCodeToHTTPStatus(code contextutils.ErrorCode) int {
101
115x
    switch code {
102
    case contextutils.ErrorCodeNoQuestionsAvailable:
103
        return http.StatusAccepted
104
    // 4xx Client Errors
105
    case contextutils.ErrorCodeInvalidInput, contextutils.ErrorCodeMissingRequired,
106
        contextutils.ErrorCodeInvalidFormat, contextutils.ErrorCodeValidationFailed,
107
69x
        contextutils.ErrorCodeOAuthStateMismatch:
108
69x
        return http.StatusBadRequest
109

110
1x
    case contextutils.ErrorCodeUnauthorized:
111
1x
        return http.StatusUnauthorized
112

113
2x
    case contextutils.ErrorCodeForbidden:
114
2x
        return http.StatusForbidden
115

116
    case contextutils.ErrorCodeRecordNotFound, contextutils.ErrorCodeQuestionNotFound,
117
14x
        contextutils.ErrorCodeAssignmentNotFound:
118
14x
        return http.StatusNotFound
119

120
6x
    case contextutils.ErrorCodeRecordExists, contextutils.ErrorCodeGenerationLimitReached:
121
6x
        return http.StatusConflict
122

123
10x
    case contextutils.ErrorCodeSessionExpired, contextutils.ErrorCodeInvalidCredentials:
124
10x
        return http.StatusUnauthorized
125

126
    case contextutils.ErrorCodeRateLimit:
127
        return http.StatusTooManyRequests
128

129
    // 5xx Server Errors
130
9x
    case contextutils.ErrorCodeInternalError:
131
9x
        return http.StatusInternalServerError
132

133
    case contextutils.ErrorCodeServiceUnavailable, contextutils.ErrorCodeDatabaseConnection,
134
1x
        contextutils.ErrorCodeAIProviderUnavailable:
135
1x
        return http.StatusServiceUnavailable
136

137
    case contextutils.ErrorCodeTimeout:
138
        return http.StatusRequestTimeout
139

140
    case contextutils.ErrorCodeDatabaseQuery, contextutils.ErrorCodeDatabaseTransaction,
141
        contextutils.ErrorCodeForeignKeyViolation, contextutils.ErrorCodeTimestampMissingTimezone,
142
        contextutils.ErrorCodeAIRequestFailed, contextutils.ErrorCodeAIResponseInvalid,
143
        contextutils.ErrorCodeAIConfigInvalid, contextutils.ErrorCodeOAuthProviderError:
144
        return http.StatusInternalServerError
145

146
    // Default to internal server error for unknown codes
147
    default:
148
        return http.StatusInternalServerError
149
    }
150
}
151


			
quizapp internal handlers worker_admin_handler.go
55.0%
Statements
127/231
1
package handlers
2

3
import (
4
    "database/sql"
5
    "fmt"
6
    "net/http"
7
    "strconv"
8
    "strings"
9
    "time"
10

11
    "github.com/gin-gonic/gin"
12

13
    "quizapp/internal/config"
14
    "quizapp/internal/models"
15
    "quizapp/internal/observability"
16
    serviceinterfaces "quizapp/internal/serviceinterfaces"
17
    "quizapp/internal/services"
18
    contextutils "quizapp/internal/utils"
19
)
20

21
// FeedbackResponse represents the JSON response for feedback listing
22
type FeedbackResponse struct {
23
    ID               int                    `json:"id"`
24
    UserID           int                    `json:"user_id"`
25
    FeedbackText     string                 `json:"feedback_text"`
26
    FeedbackType     string                 `json:"feedback_type"`
27
    ContextData      map[string]interface{} `json:"context_data"`
28
    ScreenshotData   *string                `json:"screenshot_data"`
29
    ScreenshotURL    *string                `json:"screenshot_url"`
30
    Status           string                 `json:"status"`
31
    AdminNotes       *string                `json:"admin_notes"`
32
    AssignedToUserID *int32                 `json:"assigned_to_user_id"`
33
    ResolvedAt       *string                `json:"resolved_at"`
34
    ResolvedByUserID *int32                 `json:"resolved_by_user_id"`
35
    CreatedAt        string                 `json:"created_at"`
36
    UpdatedAt        string                 `json:"updated_at"`
37
}
38

39
// ensureContextDataNotNull returns an empty map if the input is nil
40
16x
func ensureContextDataNotNull(data map[string]interface{}) map[string]interface{} {
41
16x
    if data == nil {
42
14x
        return map[string]interface{}{}
43
14x
    }
44
2x
    return data
45
}
46

47
// convertFeedbackToResponse converts FeedbackReport to FeedbackResponse
48
16x
func convertFeedbackToResponse(fr models.FeedbackReport) FeedbackResponse {
49
16x
    response := FeedbackResponse{
50
16x
        ID:           fr.ID,
51
16x
        UserID:       fr.UserID,
52
16x
        FeedbackText: fr.FeedbackText,
53
16x
        FeedbackType: fr.FeedbackType,
54
16x
        ContextData:  ensureContextDataNotNull(fr.ContextData),
55
16x
        Status:       fr.Status,
56
16x
        CreatedAt:    fr.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
57
16x
        UpdatedAt:    fr.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"),
58
16x
    }
59
16x

60
16x
    if fr.ScreenshotData.Valid {
61
1x
        response.ScreenshotData = &fr.ScreenshotData.String
62
1x
    }
63
16x
    if fr.ScreenshotURL.Valid {
64
        response.ScreenshotURL = &fr.ScreenshotURL.String
65
    }
66
16x
    if fr.AdminNotes.Valid {
67
1x
        response.AdminNotes = &fr.AdminNotes.String
68
1x
    }
69
16x
    if fr.AssignedToUserID.Valid {
70
        response.AssignedToUserID = &fr.AssignedToUserID.Int32
71
    }
72
16x
    if fr.ResolvedAt.Valid {
73
        at := fr.ResolvedAt.Time.Format("2006-01-02T15:04:05Z07:00")
74
        response.ResolvedAt = &at
75
    }
76
16x
    if fr.ResolvedByUserID.Valid {
77
        response.ResolvedByUserID = &fr.ResolvedByUserID.Int32
78
    }
79

80
16x
    return response
81
}
82

83
// FeedbackHandler handles feedback report endpoints.
84
type FeedbackHandler struct {
85
    feedbackService serviceinterfaces.FeedbackServiceInterface
86
    linearService   *services.LinearService
87
    userService     services.UserServiceInterface
88
    config          *config.Config
89
    logger          *observability.Logger
90
}
91

92
// NewFeedbackHandler creates a FeedbackHandler.
93
16x
func NewFeedbackHandler(fs serviceinterfaces.FeedbackServiceInterface, linearService *services.LinearService, userService services.UserServiceInterface, cfg *config.Config, logger *observability.Logger) *FeedbackHandler {
94
16x
    return &FeedbackHandler{
95
16x
        feedbackService: fs,
96
16x
        linearService:   linearService,
97
16x
        userService:     userService,
98
16x
        config:          cfg,
99
16x
        logger:          logger,
100
16x
    }
101
16x
}
102

103
// FeedbackSubmissionRequest represents a POST request.
104
type FeedbackSubmissionRequest struct {
105
    FeedbackText   string                 `json:"feedback_text" binding:"required"`
106
    FeedbackType   string                 `json:"feedback_type"`
107
    ContextData    map[string]interface{} `json:"context_data"`
108
    ScreenshotData string                 `json:"screenshot_data"`
109
}
110

111
// SubmitFeedback handles POST /v1/feedback.
112
14x
func (h *FeedbackHandler) SubmitFeedback(c *gin.Context) {
113
14x
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "submit_feedback")
114
14x
    defer observability.FinishSpan(span, nil)
115
14x

116
14x
    // Get user ID from Gin context (set by auth middleware)
117
14x
    userID, exists := GetUserIDFromSession(c)
118
14x
    if !exists {
119
        HandleAppError(c, contextutils.ErrUnauthorized)
120
        return
121
    }
122

123
    // Add user ID to Go context for service layers
124
14x
    ctx = contextutils.WithUserID(ctx, userID)
125
14x

126
14x
    var req FeedbackSubmissionRequest
127
14x
    if err := c.ShouldBindJSON(&req); err != nil {
128
2x
        HandleAppError(c, contextutils.NewAppErrorWithCause(
129
2x
            contextutils.ErrorCodeInvalidInput,
130
2x
            contextutils.SeverityWarn,
131
2x
            "Invalid request body",
132
2x
            "",
133
2x
            err,
134
2x
        ))
135
2x
        return
136
2x
    }
137

138
12x
    feedbackType := req.FeedbackType
139
12x
    if feedbackType == "" {
140
1x
        feedbackType = "general"
141
1x
    }
142

143
12x
    var screenshotData sql.NullString
144
12x
    if req.ScreenshotData != "" {
145
1x
        screenshotData = sql.NullString{String: req.ScreenshotData, Valid: true}
146
1x
    }
147

148
12x
    fr := &models.FeedbackReport{
149
12x
        UserID:         userID,
150
12x
        FeedbackText:   req.FeedbackText,
151
12x
        FeedbackType:   feedbackType,
152
12x
        ContextData:    req.ContextData,
153
12x
        ScreenshotData: screenshotData,
154
12x
        Status:         "new",
155
12x
    }
156
12x

157
12x
    created, err := h.feedbackService.CreateFeedback(ctx, fr)
158
12x
    if err != nil {
159
        h.logger.Error(ctx, "create feedback failed", err, nil)
160
        HandleAppError(c, err)
161
        return
162
    }
163

164
12x
    c.JSON(http.StatusCreated, convertFeedbackToResponse(*created))
165
}
166

167
// GetFeedback handles GET /v1/admin/backend/feedback/:id.
168
2x
func (h *FeedbackHandler) GetFeedback(c *gin.Context) {
169
2x
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "get_feedback")
170
2x
    defer observability.FinishSpan(span, nil)
171
2x

172
2x
    id, err := strconv.Atoi(c.Param("id"))
173
2x
    if err != nil {
174
        HandleAppError(c, contextutils.ErrInvalidFormat)
175
        return
176
    }
177

178
2x
    feedback, err := h.feedbackService.GetFeedbackByID(ctx, id)
179
2x
    if err != nil {
180
1x
        if contextutils.IsError(err, contextutils.ErrRecordNotFound) {
181
1x
            HandleAppError(c, contextutils.ErrRecordNotFound)
182
1x
            return
183
1x
        }
184
        h.logger.Error(ctx, "get feedback failed", err, nil)
185
        HandleAppError(c, err)
186
        return
187
    }
188

189
1x
    c.JSON(http.StatusOK, convertFeedbackToResponse(*feedback))
190
}
191

192
// ListFeedback handles GET /v1/admin/feedback.
193
2x
func (h *FeedbackHandler) ListFeedback(c *gin.Context) {
194
2x
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "list_feedback")
195
2x
    defer observability.FinishSpan(span, nil)
196
2x

197
2x
    page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
198
2x
    pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
199
2x
    status := c.Query("status")
200
2x
    feedbackType := c.Query("feedback_type")
201
2x
    userIDStr := c.Query("user_id")
202
2x

203
2x
    var userID *int
204
2x
    if userIDStr != "" {
205
        id, _ := strconv.Atoi(userIDStr)
206
        userID = &id
207
    }
208

209
2x
    list, total, err := h.feedbackService.GetFeedbackPaginated(ctx, page, pageSize, status, feedbackType, userID)
210
2x
    if err != nil {
211
        h.logger.Error(ctx, "list feedback failed", err, nil)
212
        HandleAppError(c, err)
213
        return
214
    }
215

216
    // Convert each feedback item to response format
217
2x
    items := make([]FeedbackResponse, len(list))
218
2x
    for i, item := range list {
219
2x
        items[i] = convertFeedbackToResponse(item)
220
2x
    }
221

222
2x
    c.JSON(http.StatusOK, gin.H{"items": items, "total": total, "page": page, "page_size": pageSize})
223
}
224

225
// UpdateFeedback handles PATCH /v1/admin/feedback/:id.
226
1x
func (h *FeedbackHandler) UpdateFeedback(c *gin.Context) {
227
1x
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "update_feedback")
228
1x
    defer observability.FinishSpan(span, nil)
229
1x

230
1x
    id, err := strconv.Atoi(c.Param("id"))
231
1x
    if err != nil {
232
        HandleAppError(c, contextutils.ErrorWithContextf("invalid feedback ID"))
233
        return
234
    }
235

236
1x
    var updates map[string]interface{}
237
1x
    if err := c.ShouldBindJSON(&updates); err != nil {
238
        HandleAppError(c, contextutils.WrapError(err, "invalid request body"))
239
        return
240
    }
241

242
1x
    updated, err := h.feedbackService.UpdateFeedback(ctx, id, updates)
243
1x
    if err != nil {
244
        h.logger.Error(ctx, "update feedback failed", err, nil)
245
        HandleAppError(c, err)
246
        return
247
    }
248

249
1x
    c.JSON(http.StatusOK, convertFeedbackToResponse(*updated))
250
}
251

252
// DeleteFeedback handles DELETE /v1/admin/backend/feedback/:id.
253
func (h *FeedbackHandler) DeleteFeedback(c *gin.Context) {
254
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "delete_feedback")
255
    defer observability.FinishSpan(span, nil)
256

257
    id, err := strconv.Atoi(c.Param("id"))
258
    if err != nil {
259
        HandleAppError(c, contextutils.ErrorWithContextf("invalid feedback ID"))
260
        return
261
    }
262

263
    err = h.feedbackService.DeleteFeedback(ctx, id)
264
    if err != nil {
265
        h.logger.Error(ctx, "delete feedback failed", err, nil)
266
        HandleAppError(c, err)
267
        return
268
    }
269

270
    c.Status(http.StatusNoContent)
271
}
272

273
// DeleteFeedbackByStatus handles DELETE /v1/admin/backend/feedback?status=resolved.
274
2x
func (h *FeedbackHandler) DeleteFeedbackByStatus(c *gin.Context) {
275
2x
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "delete_feedback_by_status")
276
2x
    defer observability.FinishSpan(span, nil)
277
2x

278
2x
    status := c.Query("status")
279
2x
    if status == "" {
280
1x
        HandleAppError(c, contextutils.ErrMissingRequired)
281
1x
        return
282
1x
    }
283

284
1x
    count, err := h.feedbackService.DeleteFeedbackByStatus(ctx, status)
285
1x
    if err != nil {
286
        h.logger.Error(ctx, "delete feedback by status failed", err, nil)
287
        HandleAppError(c, err)
288
        return
289
    }
290

291
1x
    c.JSON(http.StatusOK, gin.H{"deleted_count": count})
292
}
293

294
// DeleteAllFeedback handles DELETE /v1/admin/backend/feedback?all=true.
295
func (h *FeedbackHandler) DeleteAllFeedback(c *gin.Context) {
296
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "delete_all_feedback")
297
    defer observability.FinishSpan(span, nil)
298

299
    count, err := h.feedbackService.DeleteAllFeedback(ctx)
300
    if err != nil {
301
        h.logger.Error(ctx, "delete all feedback failed", err, nil)
302
        HandleAppError(c, err)
303
        return
304
    }
305

306
    c.JSON(http.StatusOK, gin.H{"deleted_count": count})
307
}
308

309
// CreateLinearIssueResponse represents the response for creating a Linear issue
310
type CreateLinearIssueResponse struct {
311
    IssueID  string `json:"issue_id"`
312
    IssueURL string `json:"issue_url"`
313
    Title    string `json:"title"`
314
}
315

316
// CreateLinearIssue handles POST /v1/admin/backend/feedback/:id/linear-issue.
317
3x
func (h *FeedbackHandler) CreateLinearIssue(c *gin.Context) {
318
3x
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "create_linear_issue")
319
3x
    defer observability.FinishSpan(span, nil)
320
3x

321
3x
    if h.linearService == nil {
322
        HandleAppError(c, contextutils.NewAppError(
323
            contextutils.ErrorCodeServiceUnavailable,
324
            contextutils.SeverityError,
325
            "Linear integration is not available",
326
            "",
327
        ))
328
        return
329
    }
330

331
3x
    id, err := strconv.Atoi(c.Param("id"))
332
3x
    if err != nil {
333
        HandleAppError(c, contextutils.ErrInvalidFormat)
334
        return
335
    }
336

337
    // Get feedback by ID
338
3x
    feedback, err := h.feedbackService.GetFeedbackByID(ctx, id)
339
3x
    if err != nil {
340
1x
        if contextutils.IsError(err, contextutils.ErrRecordNotFound) {
341
1x
            HandleAppError(c, contextutils.ErrRecordNotFound)
342
1x
            return
343
1x
        }
344
        h.logger.Error(ctx, "get feedback failed", err, nil)
345
        HandleAppError(c, err)
346
        return
347
    }
348

349
    // Format title - only include feedback type and number
350
2x
    title := fmt.Sprintf("[Feedback #%d] %s", feedback.ID, getTypeLabel(feedback.FeedbackType))
351
2x

352
2x
    // Get username and user for metadata
353
2x
    username := fmt.Sprintf("User %d", feedback.UserID)
354
2x
    var user *models.User
355
2x
    if h.userService != nil {
356
2x
        user, err = h.userService.GetUserByID(ctx, feedback.UserID)
357
2x
        if err == nil && user != nil {
358
2x
            username = user.Username
359
2x
        }
360
    }
361

362
    // Build description with feedback details
363
2x
    var descriptionBuilder strings.Builder
364
2x
    descriptionBuilder.WriteString(feedback.FeedbackText)
365
2x
    descriptionBuilder.WriteString("\n\n")
366
2x

367
2x
    descriptionBuilder.WriteString("### Metadata\n\n")
368
2x
    descriptionBuilder.WriteString(fmt.Sprintf("- **Type**: %s\n", getTypeLabel(feedback.FeedbackType)))
369
2x
    descriptionBuilder.WriteString(fmt.Sprintf("- **Status**: %s\n", feedback.Status))
370
2x
    descriptionBuilder.WriteString(fmt.Sprintf("- **User ID**: %d\n", feedback.UserID))
371
2x
    descriptionBuilder.WriteString(fmt.Sprintf("- **Username**: %s\n", username))
372
2x
    descriptionBuilder.WriteString(fmt.Sprintf("- **Feedback ID**: %d\n", feedback.ID))
373
2x
    // Format created timestamp in user's timezone
374
2x
    createdFormatted := feedback.CreatedAt.Format("January 2, 2006 at 3:04 PM")
375
2x
    timezoneLabel := "UTC"
376
2x
    if h.userService != nil {
377
2x
        if formatted, tz, err := contextutils.FormatTimeInUserTimezone(ctx, feedback.UserID, feedback.CreatedAt, "January 2, 2006 at 3:04 PM", h.userService.GetUserByID); err == nil {
378
2x
            createdFormatted = formatted
379
2x
            timezoneLabel = tz
380
2x
        }
381
    }
382
2x
    descriptionBuilder.WriteString(fmt.Sprintf("- **Created**: %s (%s)\n", createdFormatted, timezoneLabel))
383
2x

384
2x
    if feedback.AdminNotes.Valid && feedback.AdminNotes.String != "" {
385
        descriptionBuilder.WriteString(fmt.Sprintf("- **Admin Notes**: %s\n", feedback.AdminNotes.String))
386
    }
387

388
    // Add context data if available
389
2x
    if len(feedback.ContextData) > 0 {
390
        descriptionBuilder.WriteString("\n### Context Data\n\n")
391
        for key, value := range feedback.ContextData {
392
            switch key {
393
            case "page_url":
394
                // Handle page_url specially - make it a full URL if it's a relative path
395
                pageURL := fmt.Sprintf("%v", value)
396
                if strings.HasPrefix(pageURL, "/") {
397
                    // It's a relative path, construct full URL
398
                    // Try to get base URL from config first
399
                    baseURL := ""
400
                    if h.config != nil && h.config.Server.AppBaseURL != "" {
401
                        baseURL = h.config.Server.AppBaseURL
402
                    }
403
                    // Fallback to request headers if config not available
404
                    if baseURL == "" {
405
                        baseURL = c.Request.Header.Get("Origin")
406
                    }
407
                    if baseURL == "" {
408
                        baseURL = c.Request.Header.Get("Referer")
409
                        if baseURL != "" {
410
                            // Extract base URL from referer (protocol + host)
411
                            // Find the first "/" after the protocol
412
                            if schemeIdx := strings.Index(baseURL, "://"); schemeIdx > 0 {
413
                                if pathIdx := strings.Index(baseURL[schemeIdx+3:], "/"); pathIdx > 0 {
414
                                    baseURL = baseURL[:schemeIdx+3+pathIdx]
415
                                }
416
                            }
417
                        }
418
                    }
419
                    // Remove trailing slash if present
420
                    if baseURL != "" {
421
                        baseURL = strings.TrimSuffix(baseURL, "/")
422
                        descriptionBuilder.WriteString(fmt.Sprintf("- **%s**: %s%s\n", key, baseURL, pageURL))
423
                    } else {
424
                        // If we can't determine base URL, just use the relative path
425
                        descriptionBuilder.WriteString(fmt.Sprintf("- **%s**: %s\n", key, pageURL))
426
                    }
427
                } else {
428
                    descriptionBuilder.WriteString(fmt.Sprintf("- **%s**: %s\n", key, pageURL))
429
                }
430
            case "timestamp":
431
                // Format timestamp as human readable in user's timezone
432
                if tsStr, ok := value.(string); ok {
433
                    if ts, err := time.Parse(time.RFC3339, tsStr); err == nil {
434
                        // Convert to user's timezone
435
                        formatted := ts.Format("January 2, 2006 at 3:04 PM")
436
                        timezoneLabel := "UTC"
437
                        if h.userService != nil {
438
                            if fmtTime, tz, err := contextutils.FormatTimeInUserTimezone(ctx, feedback.UserID, ts, "January 2, 2006 at 3:04 PM", h.userService.GetUserByID); err == nil {
439
                                formatted = fmtTime
440
                                timezoneLabel = tz
441
                            }
442
                        }
443
                        descriptionBuilder.WriteString(fmt.Sprintf("- **%s**: %s (%s)\n", key, formatted, timezoneLabel))
444
                    } else {
445
                        descriptionBuilder.WriteString(fmt.Sprintf("- **%s**: %v\n", key, value))
446
                    }
447
                } else {
448
                    descriptionBuilder.WriteString(fmt.Sprintf("- **%s**: %v\n", key, value))
449
                }
450
            default:
451
                descriptionBuilder.WriteString(fmt.Sprintf("- **%s**: %v\n", key, value))
452
            }
453
        }
454
    }
455

456
    // Add screenshot - embed as base64 data URI in markdown if available
457
2x
    if feedback.ScreenshotURL.Valid && feedback.ScreenshotURL.String != "" {
458
        descriptionBuilder.WriteString("\n### Screenshot\n\n")
459
        descriptionBuilder.WriteString(fmt.Sprintf("![Screenshot](%s)\n", feedback.ScreenshotURL.String))
460
    } else if feedback.ScreenshotData.Valid && feedback.ScreenshotData.String != "" {
461
        descriptionBuilder.WriteString("\n### Screenshot\n\n")
462
        // Embed screenshot as base64 data URI
463
        screenshotData := feedback.ScreenshotData.String
464
        // Ensure it has the data URI prefix
465
        if !strings.HasPrefix(screenshotData, "data:") {
466
            screenshotData = "data:image/png;base64," + screenshotData
467
        }
468
        descriptionBuilder.WriteString(fmt.Sprintf("![Screenshot](%s)\n", screenshotData))
469
    }
470

471
2x
    descriptionBuilder.WriteString("\n---\n*Created from Quiz Admin Feedback Reports*")
472
2x

473
2x
    description := descriptionBuilder.String()
474
2x

475
2x
    // Determine labels based on feedback type
476
2x
    var labels []string
477
2x
    switch feedback.FeedbackType {
478
2x
    case "bug":
479
2x
        labels = []string{"Bug"}
480
    case "feature_request":
481
        labels = []string{"Feature"}
482
    case "improvement":
483
        labels = []string{"Improvement"}
484
    }
485

486
    // Create Linear issue (use config defaults for team and project)
487
2x
    result, err := h.linearService.CreateIssue(ctx, title, description, "", "", labels, "")
488
2x
    if err != nil {
489
1x
        h.logger.Error(ctx, "create linear issue failed", err, nil)
490
1x
        HandleAppError(c, err)
491
1x
        return
492
1x
    }
493

494
1x
    response := CreateLinearIssueResponse{
495
1x
        IssueID:  result.IssueID,
496
1x
        IssueURL: result.IssueURL,
497
1x
        Title:    result.Title,
498
1x
    }
499
1x

500
1x
    c.JSON(http.StatusOK, response)
501
}
502

503
// getTypeLabel converts feedback type to human-readable label
504
4x
func getTypeLabel(feedbackType string) string {
505
4x
    switch feedbackType {
506
4x
    case "bug":
507
4x
        return "Bug Report"
508
    case "feature_request":
509
        return "Feature Request"
510
    case "general":
511
        return "General Feedback"
512
    case "improvement":
513
        return "Improvement"
514
    default:
515
        return feedbackType
516
    }
517
}
518


			
quizapp internal handlers worker_admin_handler.go
100.0%
Statements
20/20
1
package handlers
2

3
import (
4
    "net/http"
5
    "strconv"
6
    "strings"
7

8
    "github.com/gin-gonic/gin"
9
)
10

11
// ParsePagination parses standard pagination query params from the request.
12
// It enforces bounds and applies defaults when values are missing or invalid.
13
14x
func ParsePagination(c *gin.Context, defaultPage, defaultSize, maxSize int) (int, int) {
14
14x
    pageStr := c.DefaultQuery("page", strconv.Itoa(defaultPage))
15
14x
    sizeStr := c.DefaultQuery("page_size", strconv.Itoa(defaultSize))
16
14x

17
14x
    page, err := strconv.Atoi(pageStr)
18
14x
    if err != nil || page < 1 {
19
1x
        page = defaultPage
20
1x
    }
21

22
14x
    size, err := strconv.Atoi(sizeStr)
23
14x
    if err != nil || size < 1 {
24
1x
        size = defaultSize
25
1x
    }
26
14x
    if size > maxSize {
27
1x
        size = maxSize
28
1x
    }
29

30
14x
    return page, size
31
}
32

33
// ParseFilters returns a map of non-empty trimmed query params for the given keys.
34
8x
func ParseFilters(c *gin.Context, keys ...string) map[string]string {
35
8x
    filters := make(map[string]string, len(keys))
36
8x
    for _, key := range keys {
37
31x
        if val := strings.TrimSpace(c.Query(key)); val != "" {
38
10x
            filters[key] = val
39
10x
        }
40
    }
41
8x
    return filters
42
}
43

44
// WritePaginated standardizes paginated responses with a flexible items key, pagination block, and optional extras.
45
// It preserves existing API response shapes by allowing the caller to specify the items key.
46
1x
func WritePaginated(c *gin.Context, itemsKey string, items, pagination any, extra gin.H) {
47
1x
    response := gin.H{
48
1x
        itemsKey:     items,
49
1x
        "pagination": pagination,
50
1x
    }
51
1x
    for k, v := range extra {
52
1x
        response[k] = v
53
1x
    }
54
1x
    c.JSON(http.StatusOK, response)
55
}
56


			
quizapp internal handlers worker_admin_handler.go
53.7%
Statements
293/546
1
package handlers
2

3
import (
4
    "context"
5
    "encoding/json"
6
    "fmt"
7
    "io"
8
    "math/rand"
9
    "net/http"
10
    "strconv"
11
    "strings"
12
    "time"
13

14
    "quizapp/internal/api"
15
    "quizapp/internal/models"
16
    "quizapp/internal/observability"
17
    "quizapp/internal/services"
18
    contextutils "quizapp/internal/utils"
19

20
    "quizapp/internal/config"
21

22
    "github.com/gin-gonic/gin"
23
    "go.opentelemetry.io/otel/attribute"
24
)
25

26
// QuizHandler handles quiz-related HTTP requests including questions and answers
27
type QuizHandler struct {
28
    userService     services.UserServiceInterface
29
    questionService services.QuestionServiceInterface
30
    aiService       services.AIServiceInterface
31
    learningService services.LearningServiceInterface
32
    workerService   services.WorkerServiceInterface
33
    hintService     services.GenerationHintServiceInterface
34
    usageStatsSvc   services.UsageStatsServiceInterface
35
    cfg             *config.Config
36
    logger          *observability.Logger
37
}
38

39
// NewQuizHandler creates a new QuizHandler
40
func NewQuizHandler(
41
    userService services.UserServiceInterface,
42
    questionService services.QuestionServiceInterface,
43
    aiService services.AIServiceInterface,
44
    learningService services.LearningServiceInterface,
45
    workerService services.WorkerServiceInterface,
46
    hintService services.GenerationHintServiceInterface,
47
    usageStatsSvc services.UsageStatsServiceInterface,
48
    config *config.Config,
49
    logger *observability.Logger,
50
13x
) *QuizHandler {
51
13x
    return &QuizHandler{
52
13x
        userService:     userService,
53
13x
        questionService: questionService,
54
13x
        aiService:       aiService,
55
13x
        learningService: learningService,
56
13x
        workerService:   workerService,
57
13x
        hintService:     hintService,
58
13x
        usageStatsSvc:   usageStatsSvc,
59
13x
        cfg:             config,
60
13x
        logger:          logger,
61
13x
    }
62
13x
}
63

64
// Deprecated: use GetUserIDFromSession in session.go
65
5x
func (h *QuizHandler) getUserIDFromSession(c *gin.Context) (int, bool) {
66
5x
    return GetUserIDFromSession(c)
67
5x
}
68

69
// GetQuestion handles requests for quiz questions
70
8x
func (h *QuizHandler) GetQuestion(c *gin.Context) {
71
8x
    _, span := observability.TraceHandlerFunction(c.Request.Context(), "get_question")
72
8x
    defer observability.FinishSpan(span, nil)
73
8x

74
8x
    userID, exists := GetUserIDFromSession(c)
75
8x
    if !exists {
76
        HandleAppError(c, contextutils.ErrUnauthorized)
77
        return
78
    }
79

80
    // Add span attributes for observability
81
8x
    span.SetAttributes(observability.AttributeUserID(userID))
82
8x

83
8x
    // Check if a specific question ID is requested
84
8x
    questionIDStr := c.Param("id")
85
8x
    if questionIDStr != "" {
86
5x
        span.SetAttributes(attribute.String("question.id", questionIDStr))
87
5x
        h.getSpecificQuestion(c, userID, questionIDStr)
88
5x
        return
89
5x
    }
90

91
3x
    h.getNextQuestion(c, userID)
92
}
93

94
// getSpecificQuestion improves error handling with centralized utilities
95
5x
func (h *QuizHandler) getSpecificQuestion(c *gin.Context, userID int, questionIDStr string) {
96
5x
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "get_specific_question",
97
5x
        observability.AttributeUserID(userID),
98
5x
        attribute.String("question.id_str", questionIDStr),
99
5x
    )
100
5x
    defer observability.FinishSpan(span, nil)
101
5x

102
5x
    questionID, err := strconv.Atoi(questionIDStr)
103
5x
    if err != nil {
104
1x
        HandleAppError(c, contextutils.NewAppErrorWithCause(
105
1x
            contextutils.ErrorCodeInvalidInput,
106
1x
            contextutils.SeverityWarn,
107
1x
            "Invalid question ID format",
108
1x
            "Question ID must be a valid integer",
109
1x
            err,
110
1x
        ))
111
1x
        return
112
1x
    }
113

114
4x
    questionWithStats, err := h.questionService.GetQuestionWithStats(ctx, questionID)
115
4x
    if err != nil {
116
        h.logger.Error(ctx, "Failed to get question with stats", err, map[string]interface{}{
117
            "question_id": questionID,
118
            "user_id":     userID,
119
        })
120
        HandleAppError(c, contextutils.WrapError(err, "failed to get question with stats"))
121
        return
122
    }
123

124
    // Convert and hide sensitive information
125
4x
    apiQuestion := convertQuestionToAPI(questionWithStats.Question)
126
4x
    apiQuestion.Explanation = nil // Hide explanation
127
4x

128
4x
    // Add response statistics to the API question
129
4x
    apiQuestion.CorrectCount = &questionWithStats.CorrectCount
130
4x
    apiQuestion.IncorrectCount = &questionWithStats.IncorrectCount
131
4x
    apiQuestion.TotalResponses = &questionWithStats.TotalResponses
132
4x

133
4x
    // Get user-specific confidence level if available
134
4x
    confidenceLevel, err := h.learningService.GetUserQuestionConfidenceLevel(ctx, userID, questionID)
135
4x
    if err != nil {
136
        h.logger.Warn(ctx, "Failed to get user confidence level", map[string]interface{}{
137
            "error":       err.Error(),
138
            "question_id": questionID,
139
            "user_id":     userID,
140
        })
141
        // Don't fail the request, just continue without confidence level
142
    } else if confidenceLevel != nil {
143
        apiQuestion.ConfidenceLevel = confidenceLevel
144
1x
    }
145

146
4x
    c.JSON(http.StatusOK, apiQuestion)
147
}
148

149
// getNextQuestion improves error handling with centralized utilities
150
3x
func (h *QuizHandler) getNextQuestion(c *gin.Context, userID int) {
151
3x
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "get_next_question",
152
3x
        observability.AttributeUserID(userID),
153
3x
    )
154
3x
    defer observability.FinishSpan(span, nil)
155
3x

156
3x
    user, err := h.userService.GetUserByID(ctx, userID)
157
3x
    if err != nil {
158
        h.logger.Error(ctx, "Failed to get user by ID", err, map[string]interface{}{
159
            "user_id": userID,
160
        })
161
        HandleAppError(c, contextutils.WrapError(err, "failed to get user by ID"))
162
        return
163
    }
164
3x
    if user == nil {
165
        span.SetAttributes(attribute.String("error.type", "user_nil"))
166
        HandleAppError(c, contextutils.ErrRecordNotFound)
167
        return
168
    }
169

170
    // Check if user has required preferences set
171
3x
    if !user.PreferredLanguage.Valid || user.PreferredLanguage.String == "" {
172
        span.SetAttributes(attribute.String("error.type", "missing_language_preference"))
173
        HandleAppError(c, contextutils.NewAppErrorWithCause(
174
            contextutils.ErrorCodeMissingRequired,
175
            contextutils.SeverityWarn,
176
            "Language preference not set",
177
            "Please set your preferred language in settings",
178
            nil,
179
        ))
180
        return
181
    }
182

183
3x
    if !user.CurrentLevel.Valid || user.CurrentLevel.String == "" {
184
        span.SetAttributes(attribute.String("error.type", "missing_level_preference"))
185
        HandleAppError(c, contextutils.NewAppErrorWithCause(
186
            contextutils.ErrorCodeMissingRequired,
187
            contextutils.SeverityWarn,
188
            "Level preference not set",
189
            "Please set your current level in settings",
190
            nil,
191
        ))
192
        return
193
    }
194

195
3x
    language := c.DefaultQuery("language", user.PreferredLanguage.String)
196
3x
    level := c.DefaultQuery("level", user.CurrentLevel.String)
197
3x

198
3x
    // Handle question type selection based on query parameters
199
3x
    var qType models.QuestionType
200
3x
    requestedTypes := c.Query("type")
201
3x
    strictTypeRequested := false
202
3x

203
3x
    if requestedTypes != "" {
204
1x
        strictTypeRequested = true
205
1x
        types := strings.Split(requestedTypes, ",")
206
1x
        // Use the first valid type from the list
207
1x
        for _, t := range types {
208
1x
            if t = strings.TrimSpace(t); t != "" {
209
1x
                qType = models.QuestionType(t)
210
1x
                break
211
            }
212
        }
213
2x
    } else {
214
2x
        // Check if we need to exclude certain types (comma-separated list)
215
2x
        excludeTypes := c.Query("exclude_type")
216
2x
        if excludeTypes != "" {
217
            excludeList := strings.Split(excludeTypes, ",")
218
            var excludeSet []models.QuestionType
219
            for _, t := range excludeList {
220
                if t = strings.TrimSpace(t); t != "" {
221
                    excludeSet = append(excludeSet, models.QuestionType(t))
222
                }
223
            }
224
            qType = h.selectRandomQuestionTypeExcluding(excludeSet...)
225
2x
        } else {
226
2x
            // Default random selection
227
2x
            qType = h.selectRandomQuestionType()
228
2x
        }
229
    }
230

231
    // Add span attributes for observability
232
3x
    span.SetAttributes(
233
3x
        attribute.String("language", language),
234
3x
        attribute.String("level", level),
235
3x
        attribute.String("question.type", string(qType)),
236
3x
        attribute.Bool("strict.type.requested", strictTypeRequested),
237
3x
    )
238
3x

239
3x
    // Get next question with fallback logic
240
3x
    questionWithStats, err := h.questionService.GetNextQuestion(ctx, userID, language, level, qType)
241
3x
    if err != nil {
242
        h.logger.Error(ctx, "Failed to get next question", err, map[string]interface{}{
243
            "user_id":       userID,
244
            "language":      language,
245
            "level":         level,
246
            "question_type": string(qType),
247
        })
248

249
        // Fallback: try without question type if strict type was requested
250
        if strictTypeRequested {
251
            h.logger.Info(ctx, "Attempting fallback without question type", map[string]interface{}{
252
                "user_id":  userID,
253
                "language": language,
254
                "level":    level,
255
            })
256
            questionWithStats, err = h.questionService.GetNextQuestion(ctx, userID, language, level, "")
257
            if err != nil {
258
                h.logger.Error(ctx, "Fallback also failed", err, map[string]interface{}{
259
                    "user_id":  userID,
260
                    "language": language,
261
                    "level":    level,
262
                })
263
                HandleAppError(c, contextutils.ErrNoQuestionsAvailable)
264
                return
265
            }
266
        } else {
267
            HandleAppError(c, contextutils.ErrNoQuestionsAvailable)
268
            return
269
        }
270
    }
271

272
    // Check if we got a valid question
273
3x
    if questionWithStats == nil || questionWithStats.Question == nil {
274
3x
        h.logger.Error(ctx, "GetNextQuestion returned nil question", nil, map[string]interface{}{
275
3x
            "user_id":       userID,
276
3x
            "language":      language,
277
3x
            "level":         level,
278
3x
            "question_type": string(qType),
279
3x
        })
280
3x
        // If the user strictly requested a type, record a generation hint with short TTL
281
3x
        if strictTypeRequested && h.hintService != nil && qType != "" {
282
1x
            // Best-effort; do not fail the request if hint upsert fails
283
1x
            _ = h.hintService.UpsertHint(ctx, userID, language, level, qType, 10*time.Minute)
284
1x
        }
285
3x
        c.JSON(http.StatusAccepted, api.GeneratingResponse{
286
3x
            Status:  stringPtr("generating"),
287
3x
            Message: stringPtr("No questions available. Prioritizing your requested question type. Please try again shortly."),
288
3x
        })
289
3x
        return
290
    }
291

292
    // Convert to API format and hide sensitive information
293
    apiQuestion := convertQuestionToAPI(questionWithStats.Question)
294
    apiQuestion.Explanation = nil // Hide explanation
295

296
    // Add response statistics to the API question
297
    apiQuestion.CorrectCount = &questionWithStats.CorrectCount
298
    apiQuestion.IncorrectCount = &questionWithStats.IncorrectCount
299
    apiQuestion.TotalResponses = &questionWithStats.TotalResponses
300

301
    // Add confidence level if available
302
    if questionWithStats.ConfidenceLevel != nil {
303
        apiQuestion.ConfidenceLevel = questionWithStats.ConfidenceLevel
304
    }
305

306
    c.JSON(http.StatusOK, apiQuestion)
307
}
308

309
// SubmitAnswer improves error handling with centralized utilities
310
15x
func (h *QuizHandler) SubmitAnswer(c *gin.Context) {
311
15x
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "submit_answer")
312
15x
    defer observability.FinishSpan(span, nil)
313
15x

314
15x
    userID, exists := GetUserIDFromSession(c)
315
15x
    if !exists {
316
        HandleAppError(c, contextutils.ErrUnauthorized)
317
        return
318
    }
319

320
15x
    var req api.AnswerRequest
321
15x
    if err := c.ShouldBindJSON(&req); err != nil {
322
1x
        h.logger.Error(ctx, "Invalid answer request format", err, map[string]interface{}{
323
1x
            "user_id": userID,
324
1x
        })
325
1x
        HandleAppError(c, contextutils.NewAppErrorWithCause(
326
1x
            contextutils.ErrorCodeInvalidInput,
327
1x
            contextutils.SeverityWarn,
328
1x
            "Invalid request format",
329
1x
            "",
330
1x
            err,
331
1x
        ))
332
1x
        return
333
1x
    }
334

335
    // Get the question
336
14x
    question, err := h.questionService.GetQuestionByID(ctx, int(req.QuestionId))
337
14x
    if err != nil {
338
        h.logger.Error(ctx, "Failed to get question by ID", err, map[string]interface{}{
339
            "question_id": req.QuestionId,
340
            "user_id":     userID,
341
        })
342
        HandleAppError(c, contextutils.ErrQuestionNotFound)
343
        return
344
    }
345

346
    // Check if answer is correct
347
14x
    isCorrect := int(req.UserAnswerIndex) == question.CorrectAnswer
348
14x

349
14x
    // Record user response
350
14x
    responseTimeMs := 0
351
14x
    if req.ResponseTimeMs != nil {
352
        responseTimeMs = int(*req.ResponseTimeMs)
353
    }
354

355
    // Use priority-aware recording to ensure priority scores are updated
356
    // Store the user's answer index for future reference
357
14x
    if err := h.learningService.RecordAnswerWithPriority(ctx, userID, int(req.QuestionId), int(req.UserAnswerIndex), isCorrect, responseTimeMs); err != nil {
358
        h.logger.Error(ctx, "Failed to record user response", err, map[string]interface{}{
359
            "user_id":     userID,
360
            "question_id": req.QuestionId,
361
        })
362
        HandleAppError(c, contextutils.WrapError(err, "failed to record response"))
363
        return
364
    }
365

366
    // Prepare response
367
    // Get the user's answer text from the question options
368
14x
    userAnswerText := ""
369
14x
    if optionsRaw, ok := question.Content["options"]; ok {
370
14x
        if options, ok := optionsRaw.([]interface{}); ok {
371
14x
            if int(req.UserAnswerIndex) >= 0 && int(req.UserAnswerIndex) < len(options) {
372
14x
                if optStr, ok := options[int(req.UserAnswerIndex)].(string); ok {
373
14x
                    userAnswerText = optStr
374
14x
                }
375
            }
376
        }
377
    }
378

379
14x
    answerResponse := &api.AnswerResponse{
380
14x
        IsCorrect:          &isCorrect,
381
14x
        UserAnswer:         &userAnswerText,
382
14x
        UserAnswerIndex:    &req.UserAnswerIndex,
383
14x
        Explanation:        &question.Explanation,
384
14x
        CorrectAnswerIndex: &question.CorrectAnswer,
385
14x
    }
386
14x

387
14x
    c.JSON(http.StatusOK, answerResponse)
388
14x

389
14x
    // Add span attributes for observability
390
14x
    span.SetAttributes(
391
14x
        attribute.Int("user.id", userID),
392
14x
        attribute.Int("question.id", int(req.QuestionId)),
393
14x
        attribute.Bool("answer.is_correct", isCorrect),
394
14x
        attribute.Int("response.time_ms", responseTimeMs),
395
14x
    )
396
}
397

398
// GetProgress improves error handling with centralized utilities
399
18x
func (h *QuizHandler) GetProgress(c *gin.Context) {
400
18x
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "get_progress")
401
18x
    defer observability.FinishSpan(span, nil)
402
18x

403
18x
    userID, exists := GetUserIDFromSession(c)
404
18x
    if !exists {
405
        HandleAppError(c, contextutils.ErrUnauthorized)
406
        return
407
    }
408

409
18x
    span.SetAttributes(observability.AttributeUserID(userID))
410
18x

411
18x
    progress, err := h.learningService.GetUserProgress(ctx, userID)
412
18x
    if err != nil {
413
        h.logger.Error(ctx, "Failed to get user progress", err, map[string]interface{}{
414
            "user_id": userID,
415
        })
416
        HandleAppError(c, contextutils.WrapError(err, "failed to get progress"))
417
        return
418
    }
419

420
    // Get worker status information
421
18x
    workerStatus, err := h.getWorkerStatusForUser(ctx, userID)
422
18x
    if err != nil {
423
        h.logger.Warn(ctx, "Failed to get worker status for user", map[string]interface{}{
424
            "user_id": userID,
425
            "error":   err.Error(),
426
        })
427
        // Don't fail the entire request, just log the warning
428
    }
429

430
    // Get learning preferences
431
18x
    learningPrefs, err := h.learningService.GetUserLearningPreferences(ctx, userID)
432
18x
    if err != nil {
433
        h.logger.Warn(ctx, "Failed to get learning preferences for user", map[string]interface{}{
434
            "user_id": userID,
435
            "error":   err.Error(),
436
        })
437
        // Don't fail the entire request, just log the warning
438
    }
439

440
    // Get priority insights
441
18x
    priorityInsights, err := h.getPriorityInsightsForUser(ctx, userID)
442
18x
    if err != nil {
443
        h.logger.Warn(ctx, "Failed to get priority insights for user", map[string]interface{}{
444
            "user_id": userID,
445
            "error":   err.Error(),
446
        })
447
        // Don't fail the entire request, just log the warning
448
    }
449

450
    // Get generation focus information
451
18x
    generationFocus, err := h.getGenerationFocusForUser(ctx, userID)
452
18x
    if err != nil {
453
        h.logger.Warn(ctx, "Failed to get generation focus for user", map[string]interface{}{
454
            "user_id": userID,
455
            "error":   err.Error(),
456
        })
457
        // Don't fail the entire request, just log the warning
458
    }
459

460
    // Get high priority topics
461
18x
    highPriorityTopics, err := h.getHighPriorityTopicsForUser(ctx, userID)
462
18x
    if err != nil {
463
        h.logger.Warn(ctx, "Failed to get high priority topics for user", map[string]interface{}{
464
            "user_id": userID,
465
            "error":   err.Error(),
466
        })
467
        // Don't fail the entire request, just log the warning
468
    }
469

470
    // Get gap analysis
471
18x
    gapAnalysis, err := h.getGapAnalysisForUser(ctx, userID)
472
18x
    if err != nil {
473
        h.logger.Warn(ctx, "Failed to get gap analysis for user", map[string]interface{}{
474
            "user_id": userID,
475
            "error":   err.Error(),
476
        })
477
        // Don't fail the entire request, just log the warning
478
    }
479

480
    // Get priority distribution
481
18x
    priorityDistribution, err := h.getPriorityDistributionForUser(ctx, userID)
482
18x
    if err != nil {
483
        h.logger.Warn(ctx, "Failed to get priority distribution for user", map[string]interface{}{
484
            "user_id": userID,
485
            "error":   err.Error(),
486
        })
487
        // Don't fail the entire request, just log the warning
488
    }
489

490
    // Convert models.UserProgress to api.UserProgress
491
18x
    apiProgress := convertUserProgressToAPI(ctx, progress, userID, h.userService.GetUserByID)
492
18x

493
18x
    // Add worker-related information
494
18x
    if workerStatus != nil {
495
18x
        apiProgress.WorkerStatus = workerStatus
496
18x
    }
497
18x
    if learningPrefs != nil {
498
18x
        apiProgress.LearningPreferences = convertLearningPreferencesToAPI(learningPrefs)
499
18x
    }
500
18x
    if priorityInsights != nil {
501
18x
        apiProgress.PriorityInsights = priorityInsights
502
18x
    }
503
18x
    if generationFocus != nil {
504
18x
        apiProgress.GenerationFocus = generationFocus
505
18x
    }
506
18x
    if highPriorityTopics != nil {
507
18x
        apiProgress.HighPriorityTopics = &highPriorityTopics
508
18x
    }
509
18x
    if gapAnalysis != nil {
510
18x
        apiProgress.GapAnalysis = &gapAnalysis
511
18x
    }
512
18x
    if priorityDistribution != nil {
513
18x
        apiProgress.PriorityDistribution = &priorityDistribution
514
18x
    }
515

516
18x
    c.JSON(http.StatusOK, apiProgress)
517
}
518

519
// GetAITokenUsage returns AI token usage statistics for the authenticated user
520
func (h *QuizHandler) GetAITokenUsage(c *gin.Context) {
521
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "get_ai_token_usage")
522
    defer observability.FinishSpan(span, nil)
523

524
    userID, exists := GetUserIDFromSession(c)
525
    if !exists {
526
        span.SetAttributes(attribute.String("error", "no_user_session"))
527
        HandleAppError(c, contextutils.WrapError(contextutils.ErrUnauthorized, "user not authenticated"))
528
        return
529
    }
530
    span.SetAttributes(observability.AttributeUserID(userID))
531

532
    startDateStr := c.Query("startDate")
533
    if startDateStr == "" {
534
        span.SetAttributes(attribute.String("error", "missing_start_date"))
535
        HandleAppError(c, contextutils.WrapError(contextutils.ErrInvalidInput, "startDate parameter is required"))
536
        return
537
    }
538

539
    endDateStr := c.Query("endDate")
540
    if endDateStr == "" {
541
        span.SetAttributes(attribute.String("error", "missing_end_date"))
542
        HandleAppError(c, contextutils.WrapError(contextutils.ErrInvalidInput, "endDate parameter is required"))
543
        return
544
    }
545

546
    startDate, err := time.Parse("2006-01-02", startDateStr)
547
    if err != nil {
548
        span.SetAttributes(attribute.String("error", "invalid_start_date"))
549
        HandleAppError(c, contextutils.WrapErrorf(contextutils.ErrInvalidInput, "invalid startDate format: %v", err))
550
        return
551
    }
552

553
    endDate, err := time.Parse("2006-01-02", endDateStr)
554
    if err != nil {
555
        span.SetAttributes(attribute.String("error", "invalid_end_date"))
556
        HandleAppError(c, contextutils.WrapErrorf(contextutils.ErrInvalidInput, "invalid endDate format: %v", err))
557
        return
558
    }
559

560
    // Get usage stats
561
    stats, err := h.usageStatsSvc.GetUserAITokenUsageStats(ctx, userID, startDate, endDate)
562
    if err != nil {
563
        h.logger.Error(ctx, "Failed to get user AI token usage stats", err, map[string]any{
564
            "user_id":    userID,
565
            "start_date": startDateStr,
566
            "end_date":   endDateStr,
567
        })
568
        HandleAppError(c, contextutils.WrapError(err, "failed to get AI token usage stats"))
569
        return
570
    }
571

572
    c.JSON(http.StatusOK, stats)
573
}
574

575
// GetAITokenUsageDaily returns daily aggregated AI token usage for the authenticated user
576
func (h *QuizHandler) GetAITokenUsageDaily(c *gin.Context) {
577
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "get_ai_token_usage_daily")
578
    defer observability.FinishSpan(span, nil)
579

580
    userID, exists := GetUserIDFromSession(c)
581
    if !exists {
582
        span.SetAttributes(attribute.String("error", "no_user_session"))
583
        HandleAppError(c, contextutils.WrapError(contextutils.ErrUnauthorized, "user not authenticated"))
584
        return
585
    }
586
    span.SetAttributes(observability.AttributeUserID(userID))
587

588
    startDateStr := c.Query("startDate")
589
    if startDateStr == "" {
590
        span.SetAttributes(attribute.String("error", "missing_start_date"))
591
        HandleAppError(c, contextutils.WrapError(contextutils.ErrInvalidInput, "startDate parameter is required"))
592
        return
593
    }
594

595
    endDateStr := c.Query("endDate")
596
    if endDateStr == "" {
597
        span.SetAttributes(attribute.String("error", "missing_end_date"))
598
        HandleAppError(c, contextutils.WrapError(contextutils.ErrInvalidInput, "endDate parameter is required"))
599
        return
600
    }
601

602
    startDate, err := time.Parse("2006-01-02", startDateStr)
603
    if err != nil {
604
        span.SetAttributes(attribute.String("error", "invalid_start_date"))
605
        HandleAppError(c, contextutils.WrapErrorf(contextutils.ErrInvalidInput, "invalid startDate format: %v", err))
606
        return
607
    }
608

609
    endDate, err := time.Parse("2006-01-02", endDateStr)
610
    if err != nil {
611
        span.SetAttributes(attribute.String("error", "invalid_end_date"))
612
        HandleAppError(c, contextutils.WrapErrorf(contextutils.ErrInvalidInput, "invalid endDate format: %v", err))
613
        return
614
    }
615

616
    // Get daily usage stats
617
    stats, err := h.usageStatsSvc.GetUserAITokenUsageStatsByDay(ctx, userID, startDate, endDate)
618
    if err != nil {
619
        h.logger.Error(ctx, "Failed to get user AI token usage stats by day", err, map[string]interface{}{
620
            "user_id":    userID,
621
            "start_date": startDateStr,
622
            "end_date":   endDateStr,
623
        })
624
        HandleAppError(c, contextutils.WrapError(err, "failed to get daily AI token usage stats"))
625
        return
626
    }
627

628
    c.JSON(http.StatusOK, stats)
629
}
630

631
// GetAITokenUsageHourly returns hourly aggregated AI token usage for the authenticated user on a specific day
632
func (h *QuizHandler) GetAITokenUsageHourly(c *gin.Context) {
633
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "get_ai_token_usage_hourly")
634
    defer observability.FinishSpan(span, nil)
635

636
    userID, exists := GetUserIDFromSession(c)
637
    if !exists {
638
        span.SetAttributes(attribute.String("error", "no_user_session"))
639
        HandleAppError(c, contextutils.WrapError(contextutils.ErrUnauthorized, "user not authenticated"))
640
        return
641
    }
642
    span.SetAttributes(observability.AttributeUserID(userID))
643

644
    dateStr := c.Query("date")
645
    if dateStr == "" {
646
        span.SetAttributes(attribute.String("error", "missing_date"))
647
        HandleAppError(c, contextutils.WrapError(contextutils.ErrInvalidInput, "date parameter is required"))
648
        return
649
    }
650

651
    date, err := time.Parse("2006-01-02", dateStr)
652
    if err != nil {
653
        span.SetAttributes(attribute.String("error", "invalid_date"))
654
        HandleAppError(c, contextutils.WrapErrorf(contextutils.ErrInvalidInput, "invalid date format: %v", err))
655
        return
656
    }
657

658
    // Get hourly usage stats
659
    stats, err := h.usageStatsSvc.GetUserAITokenUsageStatsByHour(ctx, userID, date)
660
    if err != nil {
661
        h.logger.Error(ctx, "Failed to get user AI token usage stats by hour", err, map[string]interface{}{
662
            "user_id": userID,
663
            "date":    dateStr,
664
        })
665
        HandleAppError(c, contextutils.WrapError(err, "failed to get hourly AI token usage stats"))
666
        return
667
    }
668

669
    c.JSON(http.StatusOK, stats)
670
}
671

672
// ReportQuestion improves error handling with centralized utilities
673
7x
func (h *QuizHandler) ReportQuestion(c *gin.Context) {
674
7x
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "report_question")
675
7x
    defer observability.FinishSpan(span, nil)
676
7x

677
7x
    userID, exists := GetUserIDFromSession(c)
678
7x
    if !exists {
679
        HandleAppError(c, contextutils.ErrUnauthorized)
680
        return
681
    }
682

683
7x
    questionIDStr := c.Param("id")
684
7x
    questionID, err := strconv.Atoi(questionIDStr)
685
7x
    if err != nil {
686
        HandleValidationError(c, "question_id", questionIDStr, "must be a valid integer")
687
        return
688
    }
689

690
    // Parse request body for report reason
691
7x
    var req struct {
692
7x
        ReportReason *string `json:"report_reason"`
693
7x
    }
694
7x

695
7x
    // Bind JSON if present (optional)
696
7x
    if err := c.ShouldBindJSON(&req); err != nil {
697
4x
        // Ignore binding errors for optional request body
698
4x
        req.ReportReason = nil
699
4x
    }
700

701
    // Get report reason, default to empty string if not provided
702
7x
    reportReason := ""
703
7x
    if req.ReportReason != nil {
704
3x
        reportReason = *req.ReportReason
705
3x
    }
706

707
7x
    span.SetAttributes(
708
7x
        observability.AttributeUserID(userID),
709
7x
        observability.AttributeQuestionID(questionID),
710
7x
    )
711
7x

712
7x
    err = h.questionService.ReportQuestion(ctx, questionID, userID, reportReason)
713
7x
    if err != nil {
714
1x
        h.logger.Error(ctx, "Failed to report question", err, map[string]interface{}{
715
1x
            "question_id": questionID,
716
1x
            "user_id":     userID,
717
1x
        })
718
1x
        if contextutils.IsError(err, contextutils.ErrRecordNotFound) {
719
1x
            HandleAppError(c, contextutils.ErrQuestionNotFound)
720
1x
            return
721
1x
        }
722
        HandleAppError(c, contextutils.WrapError(err, "failed to report question"))
723
        return
724
    }
725

726
6x
    c.JSON(http.StatusOK, api.SuccessResponse{Success: true, Message: stringPtr("Question reported successfully")})
727
}
728

729
// MarkQuestionAsKnown improves error handling with centralized utilities
730
6x
func (h *QuizHandler) MarkQuestionAsKnown(c *gin.Context) {
731
6x
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "mark_question_as_known")
732
6x
    defer observability.FinishSpan(span, nil)
733
6x

734
6x
    userID, exists := GetUserIDFromSession(c)
735
6x
    if !exists {
736
        HandleAppError(c, contextutils.ErrUnauthorized)
737
        return
738
    }
739

740
6x
    questionIDStr := c.Param("id")
741
6x
    questionID, err := strconv.Atoi(questionIDStr)
742
6x
    if err != nil {
743
1x
        HandleValidationError(c, "question_id", questionIDStr, "must be a valid integer")
744
1x
        return
745
1x
    }
746

747
    // Optional: Parse confidence level from request body
748
5x
    var req struct {
749
5x
        ConfidenceLevel *int `json:"confidence_level"`
750
5x
    }
751
5x

752
5x
    // Bind JSON if present (optional)
753
5x
    if err := c.ShouldBindJSON(&req); err != nil {
754
3x
        // Ignore binding errors for optional request body
755
3x
        req.ConfidenceLevel = nil
756
3x
    }
757

758
5x
    span.SetAttributes(
759
5x
        observability.AttributeUserID(userID),
760
5x
        observability.AttributeQuestionID(questionID),
761
5x
    )
762
5x

763
5x
    // Mark question as known with confidence level
764
5x
    err = h.learningService.MarkQuestionAsKnown(ctx, userID, questionID, req.ConfidenceLevel)
765
5x
    if err != nil {
766
1x
        h.logger.Error(ctx, "Failed to mark question as known for user", err, map[string]interface{}{
767
1x
            "question_id": questionID,
768
1x
            "user_id":     userID,
769
1x
        })
770
1x
        if contextutils.IsError(err, contextutils.ErrQuestionNotFound) {
771
1x
            HandleAppError(c, contextutils.ErrQuestionNotFound)
772
1x
            return
773
1x
        }
774
        HandleAppError(c, contextutils.WrapError(err, "failed to mark question as known"))
775
        return
776
    }
777

778
4x
    c.JSON(http.StatusOK, api.SuccessResponse{Success: true, Message: stringPtr("Question marked as known successfully")})
779
}
780

781
// ChatStream handles requests for AI-powered streaming chat about a question
782
3x
func (h *QuizHandler) ChatStream(c *gin.Context) {
783
3x
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "chat_stream")
784
3x
    defer observability.FinishSpan(span, nil)
785
3x

786
3x
    userID, exists := h.getUserIDFromSession(c)
787
3x
    if !exists {
788
        HandleAppError(c, contextutils.ErrUnauthorized)
789
        return
790
    }
791

792
3x
    var req api.QuizChatRequest
793
3x
    if err := c.ShouldBindJSON(&req); err != nil {
794
        HandleAppError(c, contextutils.NewAppErrorWithCause(
795
            contextutils.ErrorCodeInvalidInput,
796
            contextutils.SeverityWarn,
797
            "Invalid request format",
798
            "",
799
            err,
800
        ))
801
        return
802
    }
803

804
3x
    user, err := h.userService.GetUserByID(ctx, userID)
805
3x
    if err != nil || user == nil {
806
        HandleAppError(c, contextutils.ErrRecordNotFound)
807
        return
808
    }
809

810
3x
    span.SetAttributes(
811
3x
        observability.AttributeUserID(userID),
812
3x
        attribute.String("ai.provider", user.AIProvider.String),
813
3x
        attribute.String("ai.model", user.AIModel.String),
814
3x
    )
815
3x

816
3x
    // Prepare the request for the AI service
817
3x
    aiReq := &models.AIChatRequest{
818
3x
        Language:     string(*req.Question.Language),
819
3x
        Level:        string(*req.Question.Level),
820
3x
        QuestionType: models.QuestionType(*req.Question.Type),
821
3x
        UserMessage:  req.UserMessage,
822
3x
    }
823
3x

824
3x
    if req.Question.Content != nil {
825
3x
        aiReq.Question = req.Question.Content.Question
826
3x
        aiReq.Options = req.Question.Content.Options
827
3x
        if req.Question.Content.Passage != nil {
828
1x
            aiReq.Passage = *req.Question.Content.Passage
829
1x
        }
830
        // For vocabulary questions, use the sentence field as the passage
831
3x
        if req.Question.Content.Sentence != nil && req.Question.Type != nil && *req.Question.Type == "vocabulary" {
832
1x
            aiReq.Passage = *req.Question.Content.Sentence
833
1x
        }
834
    }
835

836
3x
    if req.AnswerContext != nil {
837
        if req.AnswerContext.UserAnswer != nil {
838
            aiReq.UserAnswer = *req.AnswerContext.UserAnswer
839
        }
840
        if req.AnswerContext.IsCorrect != nil {
841
            aiReq.IsCorrect = req.AnswerContext.IsCorrect
842
        }
843
    }
844

845
    // Include conversation history if provided
846
3x
    if req.ConversationHistory != nil {
847
        aiReq.ConversationHistory = make([]models.ChatMessage, len(*req.ConversationHistory))
848
        for i, msg := range *req.ConversationHistory {
849
            // Extract text content from the object
850
            contentText := ""
851
            if msg.Content.Text != nil {
852
                contentText = *msg.Content.Text
853
            }
854
            aiReq.ConversationHistory[i] = models.ChatMessage{
855
                Role:    msg.Role,
856
                Content: contentText,
857
            }
858
        }
859
    }
860

861
    // Create user AI configuration
862
3x
    userConfig := &models.UserAIConfig{
863
3x
        Provider: "", // will be set from user settings
864
3x
        Model:    "", // use service default
865
3x
        APIKey:   "",
866
3x
        Username: user.Username,
867
3x
    }
868
3x
    if user.AIProvider.Valid && user.AIProvider.String != "" {
869
        userConfig.Provider = user.AIProvider.String
870
    }
871
3x
    if user.AIModel.Valid && user.AIModel.String != "" {
872
        userConfig.Model = user.AIModel.String
873
    }
874
    // Use the new per-provider API key system instead of the old user.AIAPIKey field
875
3x
    var apiKeyID *int
876
3x
    if userConfig.Provider != "" {
877
        savedKey, keyID, err := h.userService.GetUserAPIKeyWithID(c.Request.Context(), userID, userConfig.Provider)
878
        if err == nil && savedKey != "" {
879
            userConfig.APIKey = savedKey
880
            apiKeyID = keyID
881
        }
882
    }
883

884
    // Set up Server-Sent Events headers
885
3x
    c.Header("Content-Type", "text/event-stream")
886
3x
    c.Header("Cache-Control", "no-cache")
887
3x
    c.Header("Connection", "keep-alive")
888
3x
    c.Header("Access-Control-Allow-Origin", "*")
889
3x
    c.Header("Access-Control-Allow-Headers", "Cache-Control")
890
3x

891
3x
    // Create a channel for streaming chunks
892
3x
    chunks := make(chan string, 10)
893
3x

894
3x
    // Use the request context to detect client disconnect
895
3x
    reqCtx := c.Request.Context()
896
3x

897
3x
    // Create a timeout context, but also watch for client disconnect
898
3x
    timeoutCtx, cancel := context.WithTimeout(reqCtx, config.QuizStreamTimeout)
899
3x
    defer cancel()
900
3x

901
3x
    // Combine both contexts - cancel if either times out or client disconnects
902
3x
    ctx, combinedCancel := context.WithCancel(timeoutCtx)
903
3x
    defer combinedCancel()
904
3x

905
3x
    // Store userID and apiKeyID in context for usage tracking
906
3x
    // This context will be used by the AI service for usage tracking
907
3x
    ctx = contextutils.WithUserID(ctx, userID)
908
3x
    if apiKeyID != nil {
909
        ctx = contextutils.WithAPIKeyID(ctx, *apiKeyID)
910
    }
911

912
    // Watch for client disconnect
913
3x
    go func() {
914
3x
        defer func() {
915
3x
            if r := recover(); r != nil {
916
                h.logger.Error(ctx, "Panic in client disconnect watcher", nil, map[string]any{
917
                    "panic": r,
918
                })
919
            }
920
        }()
921
3x
        select {
922
        case <-reqCtx.Done():
923
            combinedCancel() // Cancel if client disconnects
924
3x
        case <-ctx.Done():
925
            // Context already cancelled
926
        }
927
    }()
928

929
    // Start the AI streaming in a goroutine
930
3x
    go func() {
931
3x
        defer func() {
932
3x
            if r := recover(); r != nil {
933
                h.logger.Error(ctx, "Panic in AI streaming goroutine", nil, map[string]interface{}{
934
                    "panic": r,
935
                })
936
            }
937
3x
            close(chunks) // Close the channel when the goroutine completes
938
        }()
939
3x
        if err := h.aiService.GenerateChatResponseStream(ctx, userConfig, aiReq, chunks); err != nil {
940
3x
            h.logger.Error(ctx, "AI chat streaming failed for user", err, map[string]interface{}{
941
3x
                "user_id": contextutils.GetUserIDFromContext(ctx),
942
3x
            })
943
3x
            // Only send error if context is not cancelled (avoid sending to closed channel)
944
3x
            if ctx.Err() == nil {
945
3x
                select {
946
3x
                case chunks <- fmt.Sprintf("ERROR: %v", err):
947
                default:
948
                    // Channel full, skip sending error
949
                }
950
            }
951
        }
952
    }()
953

954
    // Stream the response chunks
955
3x
    c.Stream(func(w io.Writer) bool {
956
3x
        select {
957
3x
        case chunk, ok := <-chunks:
958
3x
            if !ok {
959
                // Channel closed, end streaming
960
                return false
961
            }
962

963
            // Handle error messages
964
3x
            if strings.HasPrefix(chunk, "ERROR: ") {
965
3x
                c.SSEvent("error", chunk[7:]) // Remove "ERROR: " prefix
966
3x
                return false
967
3x
            }
968

969
            // Marshal the chunk to JSON to ensure newlines and special characters are preserved.
970
            jsonChunk, err := json.Marshal(chunk)
971
            if err != nil {
972
                h.logger.Error(ctx, "Failed to marshal chat stream chunk to JSON", err)
973
                return true // Continue streaming, skip this chunk
974
            }
975

976
            // Send normal content chunk in proper SSE format
977
            if _, err := fmt.Fprintf(w, "data: %s\n\n", jsonChunk); err != nil {
978
                h.logger.Error(ctx, "Failed to write chat stream data", err)
979
                return false
980
            }
981
            c.Writer.Flush()
982
            return true
983
        case <-ctx.Done():
984
            c.SSEvent("error", "Request timeout")
985
            return false
986
        }
987
    })
988
}
989

990
// Helper methods
991

992
2x
func (h *QuizHandler) selectRandomQuestionType() models.QuestionType {
993
2x
    // Note: This is a pure function that doesn't need tracing since it doesn't make external calls
994
2x
    types := []models.QuestionType{
995
2x
        models.Vocabulary,
996
2x
        models.FillInBlank,
997
2x
        models.QuestionAnswer,
998
2x
        models.ReadingComprehension,
999
2x
    }
1000
2x
    return types[rand.Intn(len(types))]
1001
2x
}
1002

1003
// selectRandomQuestionTypeExcluding returns a random question type excluding the specified types
1004
func (h *QuizHandler) selectRandomQuestionTypeExcluding(excludeTypes ...models.QuestionType) models.QuestionType {
1005
    availableTypes := []models.QuestionType{
1006
        models.Vocabulary,
1007
        models.FillInBlank,
1008
        models.QuestionAnswer,
1009
        models.ReadingComprehension,
1010
    }
1011

1012
    // Filter out excluded types
1013
    for _, excludeType := range excludeTypes {
1014
        for i, availableType := range availableTypes {
1015
            if availableType == excludeType {
1016
                availableTypes = append(availableTypes[:i], availableTypes[i+1:]...)
1017
                break
1018
            }
1019
        }
1020
    }
1021

1022
    if len(availableTypes) == 0 {
1023
        return models.Vocabulary // Default fallback
1024
    }
1025

1026
    return availableTypes[rand.Intn(len(availableTypes))]
1027
}
1028

1029
// GetWorkerStatus returns worker status and error information for the current user
1030
2x
func (h *QuizHandler) GetWorkerStatus(c *gin.Context) {
1031
2x
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "get_worker_status")
1032
2x
    defer observability.FinishSpan(span, nil)
1033
2x

1034
2x
    userID, exists := h.getUserIDFromSession(c)
1035
2x
    if !exists {
1036
        HandleAppError(c, contextutils.ErrUnauthorized)
1037
        return
1038
    }
1039

1040
2x
    span.SetAttributes(observability.AttributeUserID(userID))
1041
2x

1042
2x
    // Get worker health information
1043
2x
    workerHealth, err := h.workerService.GetWorkerHealth(ctx)
1044
2x
    if err != nil {
1045
        h.logger.Error(ctx, "Failed to get worker health", err)
1046
        HandleAppError(c, contextutils.WrapError(err, "failed to get worker status"))
1047
        return
1048
    }
1049

1050
    // Check if user is paused
1051
2x
    userPaused, err := h.workerService.IsUserPaused(ctx, userID)
1052
2x
    if err != nil {
1053
        h.logger.Error(ctx, "Failed to check user pause status", err, nil)
1054
        userPaused = false // Default to not paused if check fails
1055
    }
1056

1057
    // Check if global pause is active
1058
2x
    globalPaused, err := h.workerService.IsGlobalPaused(ctx)
1059
2x
    if err != nil {
1060
        h.logger.Error(ctx, "Failed to check global pause status", err, nil)
1061
        globalPaused = false // Default to not paused if check fails
1062
    }
1063

1064
    // Extract relevant information for the user
1065
2x
    response := gin.H{
1066
2x
        "has_errors":         false,
1067
2x
        "error_message":      "",
1068
2x
        "global_paused":      globalPaused,
1069
2x
        "user_paused":        userPaused,
1070
2x
        "healthy_workers":    workerHealth["healthy_count"],
1071
2x
        "total_workers":      workerHealth["total_count"],
1072
2x
        "last_error_details": "",
1073
2x
        "worker_running":     false,
1074
2x
    }
1075
2x

1076
2x
    // Check for worker errors
1077
2x
    if workerInstances, ok := workerHealth["worker_instances"].([]map[string]interface{}); ok {
1078
2x
        for _, instance := range workerInstances {
1079
            if lastError, hasError := instance["last_run_error"]; hasError && lastError != nil {
1080
                // Only handle string type
1081
                if errorStr, ok := lastError.(string); ok && errorStr != "" {
1082
                    response["has_errors"] = true
1083
                    response["error_message"] = "Worker encountered errors during question generation"
1084
                    response["last_error_details"] = errorStr
1085
                    break
1086
                }
1087
            }
1088
            if isRunning, ok := instance["is_running"].(bool); ok && isRunning {
1089
                response["worker_running"] = true
1090
            }
1091
        }
1092
    }
1093

1094
2x
    c.JSON(http.StatusOK, response)
1095
}
1096

1097
// Helper functions for enhanced progress information
1098

1099
18x
func (h *QuizHandler) getWorkerStatusForUser(ctx context.Context, userID int) (*api.WorkerStatus, error) {
1100
18x
    // Get worker health information
1101
18x
    workerHealth, err := h.workerService.GetWorkerHealth(ctx)
1102
18x
    if err != nil {
1103
        return nil, err
1104
    }
1105

1106
    // Check if user is paused
1107
18x
    userPaused, err := h.workerService.IsUserPaused(ctx, userID)
1108
18x
    if err != nil {
1109
        userPaused = false // Default to not paused if check fails
1110
    }
1111

1112
    // Check if global pause is active
1113
18x
    globalPaused, err := h.workerService.IsGlobalPaused(ctx)
1114
18x
    if err != nil {
1115
        globalPaused = false // Default to not paused if check fails
1116
    }
1117

1118
    // Determine worker status
1119
18x
    var status api.WorkerStatusStatus
1120
18x
    var errorMessage *string
1121
18x

1122
18x
    if globalPaused {
1123
        status = api.Idle // Use idle for paused state
1124
    } else if userPaused {
1125
        status = api.Idle // Use idle for paused state
1126
    } else {
1127
18x
        status = api.Idle // Default to idle
1128
18x
        // Check for worker errors and actual activity
1129
18x
        if workerInstances, ok := workerHealth["worker_instances"].([]map[string]interface{}); ok {
1130
18x
            for _, instance := range workerInstances {
1131
                // Check for errors first
1132
                if lastError, hasError := instance["last_run_error"]; hasError && lastError != nil {
1133
                    if errorStr, ok := lastError.(string); ok && errorStr != "" {
1134
                        // For errors, we'll use idle status but set the error message
1135
                        status = api.Idle
1136
                        errorMessage = &errorStr
1137
                        break
1138
                    }
1139
                }
1140

1141
                // Check if worker is running AND has recent activity
1142
                if isRunning, ok := instance["is_running"].(bool); ok && isRunning {
1143
                    // Only set to busy if the worker is actually active (not just running but idle)
1144
                    // We'll check if there's recent activity or if the worker is actively generating
1145
                    if lastHeartbeat, hasHeartbeat := instance["last_heartbeat"]; hasHeartbeat && lastHeartbeat != nil {
1146
                        if heartbeatStr, ok := lastHeartbeat.(string); ok {
1147
                            if heartbeat, err := time.Parse(time.RFC3339, heartbeatStr); err == nil {
1148
                                // Consider busy if heartbeat is very recent (within last 30 seconds)
1149
                                if time.Since(heartbeat) < 30*time.Second {
1150
                                    status = api.Busy
1151
                                }
1152
                            }
1153
                        }
1154
                    }
1155
                }
1156
            }
1157
        }
1158
    }
1159

1160
    // Get last heartbeat
1161
18x
    var lastHeartbeat *time.Time
1162
18x
    if workerInstances, ok := workerHealth["worker_instances"].([]map[string]interface{}); ok && len(workerInstances) > 0 {
1163
        if heartbeatStr, ok := workerInstances[0]["last_heartbeat"].(string); ok {
1164
            if heartbeat, err := time.Parse(time.RFC3339, heartbeatStr); err == nil {
1165
                lastHeartbeat = &heartbeat
1166
            }
1167
        }
1168
    }
1169

1170
18x
    return &api.WorkerStatus{
1171
18x
        Status:        &status,
1172
18x
        LastHeartbeat: formatTimePointer(lastHeartbeat),
1173
18x
        ErrorMessage:  errorMessage,
1174
18x
    }, nil
1175
}
1176

1177
18x
func (h *QuizHandler) getPriorityInsightsForUser(ctx context.Context, userID int) (*api.PriorityInsights, error) {
1178
18x
    // Get priority distribution for the user
1179
18x
    priorityDistribution, err := h.learningService.GetUserPriorityScoreDistribution(ctx, userID)
1180
18x
    if err != nil {
1181
        return nil, err
1182
    }
1183

1184
    // Extract counts from distribution
1185
18x
    highCount := 0
1186
18x
    mediumCount := 0
1187
18x
    lowCount := 0
1188
18x
    totalCount := 0
1189
18x

1190
18x
    if high, ok := priorityDistribution["high"].(int); ok {
1191
18x
        highCount = high
1192
18x
        totalCount += high
1193
18x
    }
1194
18x
    if medium, ok := priorityDistribution["medium"].(int); ok {
1195
18x
        mediumCount = medium
1196
18x
        totalCount += medium
1197
18x
    }
1198
18x
    if low, ok := priorityDistribution["low"].(int); ok {
1199
18x
        lowCount = low
1200
18x
        totalCount += low
1201
18x
    }
1202

1203
18x
    return &api.PriorityInsights{
1204
18x
        TotalQuestionsInQueue:   &totalCount,
1205
18x
        HighPriorityQuestions:   &highCount,
1206
18x
        MediumPriorityQuestions: &mediumCount,
1207
18x
        LowPriorityQuestions:    &lowCount,
1208
18x
    }, nil
1209
}
1210

1211
18x
func (h *QuizHandler) getGenerationFocusForUser(ctx context.Context, userID int) (*api.GenerationFocus, error) {
1212
18x
    // Get user's AI configuration
1213
18x
    user, err := h.userService.GetUserByID(ctx, userID)
1214
18x
    if err != nil {
1215
        return nil, err
1216
    }
1217

1218
    // Get current generation model
1219
18x
    model := "default"
1220
18x
    if user.AIModel.Valid && user.AIModel.String != "" {
1221
17x
        model = user.AIModel.String
1222
17x
    }
1223

1224
    // Get last generation time (simplified - could be enhanced with actual generation logs)
1225
18x
    lastGenerationTime := time.Now().Add(-time.Hour) // Placeholder
1226
18x

1227
18x
    // Get generation rate (simplified - could be enhanced with actual metrics)
1228
18x
    generationRate := float32(2.5) // Placeholder: average questions per minute
1229
18x

1230
18x
    return &api.GenerationFocus{
1231
18x
        CurrentGenerationModel: &model,
1232
18x
        LastGenerationTime:     formatTimePtr(lastGenerationTime),
1233
18x
        GenerationRate:         &generationRate,
1234
18x
    }, nil
1235
}
1236

1237
18x
func (h *QuizHandler) getHighPriorityTopicsForUser(ctx context.Context, userID int) ([]string, error) {
1238
18x
    // Get high priority topics from learning service
1239
18x
    topics, err := h.learningService.GetHighPriorityTopics(ctx, userID)
1240
18x
    if err != nil {
1241
        return nil, err
1242
    }
1243
18x
    return topics, nil
1244
}
1245

1246
18x
func (h *QuizHandler) getGapAnalysisForUser(ctx context.Context, userID int) (map[string]interface{}, error) {
1247
18x
    // Get gap analysis from learning service
1248
18x
    gapAnalysis, err := h.learningService.GetGapAnalysis(ctx, userID)
1249
18x
    if err != nil {
1250
        return nil, err
1251
    }
1252
18x
    return gapAnalysis, nil
1253
}
1254

1255
18x
func (h *QuizHandler) getPriorityDistributionForUser(ctx context.Context, userID int) (map[string]int, error) {
1256
18x
    // Get priority distribution from learning service
1257
18x
    distribution, err := h.learningService.GetPriorityDistribution(ctx, userID)
1258
18x
    if err != nil {
1259
        return nil, err
1260
    }
1261
18x
    return distribution, nil
1262
}
1263

1264
26x
func convertLearningPreferencesToAPI(prefs *models.UserLearningPreferences) *api.UserLearningPreferences {
1265
26x
    out := &api.UserLearningPreferences{
1266
26x
        FocusOnWeakAreas:     prefs.FocusOnWeakAreas,
1267
26x
        FreshQuestionRatio:   float32(prefs.FreshQuestionRatio),
1268
26x
        KnownQuestionPenalty: float32(prefs.KnownQuestionPenalty),
1269
26x
        ReviewIntervalDays:   prefs.ReviewIntervalDays,
1270
26x
        WeakAreaBoost:        float32(prefs.WeakAreaBoost),
1271
26x
        DailyReminderEnabled: prefs.DailyReminderEnabled,
1272
26x
    }
1273
26x
    if prefs.TTSVoice != "" {
1274
1x
        v := prefs.TTSVoice
1275
1x
        out.TtsVoice = &v
1276
1x
    }
1277
26x
    if prefs.DailyGoal > 0 {
1278
22x
        dg := prefs.DailyGoal
1279
22x
        out.DailyGoal = &dg
1280
22x
    }
1281
26x
    return out
1282
}
1283


			
quizapp internal handlers worker_admin_handler.go
31.4%
Statements
11/35
1
package handlers
2

3
import (
4
    "fmt"
5
    "net/http"
6
    "sort"
7
    "strings"
8
    "time"
9

10
    "quizapp/internal/observability"
11

12
    "github.com/gin-gonic/gin"
13
)
14

15
// RouteInfo represents information about a single route
16
type RouteInfo struct {
17
    Method      string `json:"method"`
18
    Path        string `json:"path"`
19
    HandlerName string `json:"handler_name"`
20
}
21

22
// RouteListingHandler generates automatic route listings
23
type RouteListingHandler struct {
24
    serviceName string
25
    routes      []RouteInfo
26
}
27

28
// NewRouteListingHandler creates a new route listing handler
29
29x
func NewRouteListingHandler(serviceName string) *RouteListingHandler {
30
29x
    return &RouteListingHandler{
31
29x
        serviceName: serviceName,
32
29x
        routes:      []RouteInfo{},
33
29x
    }
34
29x
}
35

36
// CollectRoutes extracts all routes from a Gin engine
37
27x
func (h *RouteListingHandler) CollectRoutes(engine *gin.Engine) {
38
27x
    h.routes = []RouteInfo{}
39
27x

40
27x
    // Get all routes from the Gin engine
41
27x
    routes := engine.Routes()
42
27x

43
27x
    for _, route := range routes {
44
1869x
        // Skip internal Gin routes
45
1869x
        if strings.HasPrefix(route.Path, "/debug/") {
46
            continue
47
        }
48

49
1869x
        h.routes = append(h.routes, RouteInfo{
50
1869x
            Method:      route.Method,
51
1869x
            Path:        route.Path,
52
1869x
            HandlerName: route.Handler,
53
1869x
        })
54
    }
55

56
    // Sort routes by path for better organization
57
27x
    sort.Slice(h.routes, func(i, j int) bool {
58
12901x
        return h.routes[i].Path < h.routes[j].Path
59
12901x
    })
60
}
61

62
// GetRouteListingPage shows all available routes as HTML
63
func (h *RouteListingHandler) GetRouteListingPage(c *gin.Context) {
64
    _, span := observability.TraceHandlerFunction(c.Request.Context(), "get_route_listing_page")
65
    defer observability.FinishSpan(span, nil)
66
    html := h.generateHTML()
67
    // Add no-cache headers
68
    c.Header("Content-Type", "text/html; charset=utf-8")
69
    c.Header("Cache-Control", "no-cache, no-store, must-revalidate")
70
    c.Header("Pragma", "no-cache")
71
    c.Header("Expires", "0")
72
    c.String(http.StatusOK, html)
73
}
74

75
// GetRouteListingJSON returns the route listing as JSON
76
5x
func (h *RouteListingHandler) GetRouteListingJSON(c *gin.Context) {
77
5x
    _, span := observability.TraceHandlerFunction(c.Request.Context(), "get_route_listing_json")
78
5x
    defer observability.FinishSpan(span, nil)
79
5x
    c.JSON(http.StatusOK, h.routes)
80
5x
}
81

82
// generateHTML creates an HTML page listing all routes
83
func (h *RouteListingHandler) generateHTML() string {
84
    var html strings.Builder
85

86
    html.WriteString(`<!DOCTYPE html>
87
<html lang="en">
88
<head>
89
    <meta charset="UTF-8">
90
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
91
    <title>` + h.serviceName + ` - Available Routes</title>
92
    <style>
93
        body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; line-height: 1.6; padding: 20px; background-color: #f8f9fa; color: #212529; }
94
        .container { max-width: 1200px; margin: auto; background: #fff; padding: 30px; border-radius: 8px; box-shadow: 0 4px 8px rgba(0,0,0,0.05); }
95
        h1 { color: #0056b3; border-bottom: 2px solid #dee2e6; padding-bottom: 10px; margin-bottom: 30px; }
96
        .service-info { background: #e7f3ff; padding: 15px; border-radius: 5px; margin-bottom: 30px; }
97
        .route-table { width: 100%; border-collapse: collapse; margin-bottom: 30px; }
98
        .route-table th, .route-table td { padding: 12px; text-align: left; border-bottom: 1px solid #dee2e6; }
99
        .route-table th { background-color: #f8f9fa; font-weight: 600; color: #495057; }
100
        .route-table tr:nth-child(even) { background-color: #f8f9fa; }
101
        .route-table tr:hover { background-color: #e9ecef; }
102
        .method { display: inline-block; padding: 4px 8px; border-radius: 4px; font-size: 12px; font-weight: bold; min-width: 60px; text-align: center; }
103
        .method-get { background-color: #d4edda; color: #155724; }
104
        .method-post { background-color: #cce5ff; color: #004085; }
105
        .method-put { background-color: #fff3cd; color: #856404; }
106
        .method-delete { background-color: #f8d7da; color: #721c24; }
107
        .method-patch { background-color: #e2e3e5; color: #383d41; }
108
        .path { font-family: "Monaco", "Menlo", "Ubuntu Mono", monospace; font-size: 14px; color: #6f42c1; }
109
        .clickable-path { cursor: pointer; text-decoration: underline; }
110
        .clickable-path:hover { background-color: #f8f9fa; }
111
        .footer { margin-top: 30px; text-align: center; color: #6c757d; font-size: 14px; }
112
        .stats { display: flex; gap: 20px; margin-bottom: 20px; }
113
        .stat-box { background: #ffffff; border: 1px solid #dee2e6; padding: 15px; border-radius: 5px; text-align: center; flex: 1; }
114
        .stat-number { font-size: 24px; font-weight: bold; color: #0056b3; }
115
        .stat-label { color: #6c757d; font-size: 14px; }
116
    </style>
117
</head>
118
<body>
119
    <div class="container">
120
        <h1>` + h.serviceName + ` Service - Available Routes</h1>
121

122
        <div class="service-info">
123
            <strong>Service:</strong> ` + h.serviceName + `<br>
124
            <strong>Generated:</strong> ` + time.Now().Format("2006-01-02 15:04:05") + `<br>
125
            <strong>Total Routes:</strong> ` + fmt.Sprintf("%d", len(h.routes)) + `
126
        </div>
127

128
        <div class="stats">
129
            <div class="stat-box">
130
                <div class="stat-number">` + fmt.Sprintf("%d", len(h.routes)) + `</div>
131
                <div class="stat-label">Total Routes</div>
132
            </div>
133
            <div class="stat-box">
134
                <div class="stat-number">` + fmt.Sprintf("%d", h.countMethods("GET")) + `</div>
135
                <div class="stat-label">GET Routes</div>
136
            </div>
137
            <div class="stat-box">
138
                <div class="stat-number">` + fmt.Sprintf("%d", h.countMethods("POST")) + `</div>
139
                <div class="stat-label">POST Routes</div>
140
            </div>
141
        </div>
142

143
        <table class="route-table">
144
            <thead>
145
                <tr>
146
                    <th>Method</th>
147
                    <th>Path</th>
148
                    <th>Handler</th>
149
                </tr>
150
            </thead>
151
            <tbody>`)
152

153
    for _, route := range h.routes {
154
        methodClass := "method-" + strings.ToLower(route.Method)
155
        pathClass := "path"
156

157
        // Make paths clickable for GET routes
158
        if route.Method == "GET" {
159
            pathClass += " clickable-path"
160
        }
161

162
        html.WriteString(fmt.Sprintf(`
163
                <tr>
164
                    <td><span class="method %s">%s</span></td>
165
                    <td><span class="%s" onclick="navigateToRoute('%s', '%s')">%s</span></td>
166
                    <td>%s</td>
167
                </tr>`,
168
            methodClass, route.Method,
169
            pathClass, route.Method, route.Path, route.Path,
170
            route.HandlerName,
171
        ))
172
    }
173

174
    html.WriteString(`
175
            </tbody>
176
        </table>
177

178
        <div class="footer">
179
            <p>Click on any GET route path to navigate to it | <a href="/?json=true">View as JSON</a></p>
180
        </div>
181
    </div>
182

183
    <script>
184
        function navigateToRoute(method, path) {
185
            if (method === 'GET') {
186
                window.location.href = path;
187
            } else {
188
                alert('Only GET routes can be navigated to directly. Use API client for ' + method + ' requests.');
189
            }
190
        }
191
    </script>
192
</body>
193
</html>`)
194

195
    return html.String()
196
}
197

198
// countMethods counts routes by HTTP method
199
func (h *RouteListingHandler) countMethods(method string) int {
200
    count := 0
201
    for _, route := range h.routes {
202
        if route.Method == method {
203
            count++
204
        }
205
    }
206
    return count
207
}
208


			
quizapp internal handlers worker_admin_handler.go
96.1%
Statements
272/283
1
package handlers
2

3
import (
4
    "encoding/json"
5
    "net/http"
6
    "os"
7
    "strings"
8
    "time"
9

10
    "github.com/gin-contrib/cors"
11
    "github.com/gin-contrib/secure"
12
    "github.com/gin-contrib/sessions"
13
    "github.com/gin-contrib/sessions/cookie"
14
    "github.com/gin-gonic/gin"
15
    "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
16

17
    "quizapp/internal/config"
18
    "quizapp/internal/middleware"
19
    "quizapp/internal/observability"
20
    "quizapp/internal/services"
21
    "quizapp/internal/version"
22
)
23

24
// IMPORTANT: When adding new API endpoints, make sure to:
25
// 1. Add them to swagger.yaml with proper documentation
26
// 2. Run `task generate-api-types` to regenerate types
27
// 3. Update any relevant tests
28
// 4. Consider if the endpoint should be public or admin-only
29

30
// NewRouter creates a new router factory with all the necessary middleware and routes
31
func NewRouter(
32
    cfg *config.Config,
33
    userService services.UserServiceInterface,
34
    questionService services.QuestionServiceInterface,
35
    learningService services.LearningServiceInterface,
36
    aiService services.AIServiceInterface,
37
    workerService services.WorkerServiceInterface,
38
    dailyQuestionService services.DailyQuestionServiceInterface,
39
    storyService services.StoryServiceInterface,
40
    conversationService services.ConversationServiceInterface,
41
    oauthService *services.OAuthService,
42
    generationHintService services.GenerationHintServiceInterface,
43
    translationService services.TranslationServiceInterface,
44
    snippetsService services.SnippetsServiceInterface,
45
    usageStatsService services.UsageStatsServiceInterface,
46
    wordOfTheDayService services.WordOfTheDayServiceInterface,
47
    authAPIKeyService services.AuthAPIKeyServiceInterface,
48
    logger *observability.Logger,
49
13x
) *gin.Engine {
50
13x
    // Setup Gin router
51
13x
    router := gin.New()
52
13x
    router.Use(gin.Recovery())
53
13x

54
13x
    // Add HTTP request logging middleware using our observability logger
55
13x
    router.Use(func(c *gin.Context) {
56
523x
        start := time.Now()
57
523x

58
523x
        // Process request
59
523x
        c.Next()
60
523x

61
523x
        // Log request details using our observability logger
62
523x
        latency := time.Since(start)
63
523x
        statusCode := c.Writer.Status()
64
523x
        clientIP := c.ClientIP()
65
523x
        method := c.Request.Method
66
523x
        path := c.Request.URL.Path
67
523x

68
523x
        // Create structured log entry
69
523x
        fields := map[string]interface{}{
70
523x
            "http.method":      method,
71
523x
            "http.path":        path,
72
523x
            "http.status_code": statusCode,
73
523x
            "http.latency_ms":  latency.Milliseconds(),
74
523x
            "http.client_ip":   clientIP,
75
523x
            "http.user_agent":  c.Request.UserAgent(),
76
523x
        }
77
523x

78
523x
        // Add error message if present
79
523x
        if len(c.Errors) > 0 {
80
            fields["http.error"] = c.Errors.String()
81
        }
82

83
        // For failed requests (4xx and 5xx), capture response body for debugging
84
523x
        if statusCode >= 400 {
85
111x
            // Get response body for error requests
86
111x
            if c.Writer.Size() > 0 {
87
111x
                // Try to capture response body for debugging
88
111x
                // Note: This is a best effort since the response may have already been written
89
111x
                fields["http.response_size"] = c.Writer.Size()
90
111x
            }
91

92
            // Add more context for 5xx errors
93
111x
            if statusCode >= 500 {
94
7x
                fields["http.error_type"] = "server_error"
95
7x
                // Log additional context that might help debugging
96
7x
                if c.Request.Body != nil {
97
                    fields["http.request_has_body"] = true
98
                }
99
104x
            } else {
100
104x
                fields["http.error_type"] = "client_error"
101
104x
            }
102
        }
103

104
        // Log using our observability logger (goes to both stdout and OTLP)
105
        // Use appropriate log level based on status code
106
523x
        if statusCode >= 500 {
107
7x
            logger.Error(c.Request.Context(), "HTTP request failed", nil, fields)
108
7x
        } else if statusCode >= 400 {
109
            logger.Warn(c.Request.Context(), "HTTP request warning", fields)
110
104x
        } else {
111
412x
            logger.Info(c.Request.Context(), "HTTP request", fields)
112
412x
        }
113
    })
114

115
    // Health check endpoint (defined before any middleware)
116
13x
    router.GET("/health", func(c *gin.Context) {
117
1x
        c.JSON(http.StatusOK, gin.H{"status": "ok", "service": "backend"})
118
1x
    })
119

120
    // Add OpenTelemetry middleware for HTTP tracing and context propagation with automatic error attributes
121
13x
    router.Use(observability.GinMiddlewareWithErrorHandling("quiz-backend"))
122
13x

123
13x
    // Add response validation middleware for API endpoints
124
13x
    router.Use(middleware.ResponseValidationMiddleware(logger))
125
13x

126
13x
    // Swagger documentation (defined before middleware)
127
13x
    router.StaticFile("/swagger.yaml", "./swagger.yaml")
128
13x
    router.StaticFile("/swaggerz", "./swaggerz.html")
129
13x

130
13x
    // Disable automatic redirection for trailing slashes, which is better for APIs
131
13x
    router.RedirectTrailingSlash = false
132
13x

133
13x
    // Setup CORS middleware
134
13x
    corsConfig := cors.DefaultConfig()
135
13x
    corsConfig.AllowOrigins = cfg.Server.CORSOrigins
136
13x
    corsConfig.AllowCredentials = true
137
13x
    corsConfig.AllowHeaders = []string{"Origin", "Content-Length", "Content-Type", "Authorization", "X-Requested-With"}
138
13x
    corsConfig.AllowMethods = []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}
139
13x
    router.Use(cors.New(corsConfig))
140
13x

141
13x
    // Setup session middleware
142
13x
    store := cookie.NewStore([]byte(cfg.Server.SessionSecret))
143
13x
    // Configure session options for security
144
13x
    sessionOpts := sessions.Options{
145
13x
        Path:     config.SessionPath,
146
13x
        MaxAge:   int(config.SessionMaxAge.Seconds()),
147
13x
        HttpOnly: config.SessionHTTPOnly,
148
13x
        Secure:   config.SessionSecure, // Set to true in production with HTTPS
149
13x
    }
150
13x
    if cfg.Server.Debug {
151
13x
        sessionOpts.SameSite = http.SameSiteDefaultMode
152
13x
    } else {
153
        sessionOpts.SameSite = http.SameSiteLaxMode
154
        sessionOpts.Secure = true
155
    }
156
13x
    store.Options(sessionOpts)
157
13x
    router.Use(sessions.Sessions(config.SessionName, store))
158
13x

159
13x
    // Setup Gin mode
160
13x
    gin.SetMode(gin.ReleaseMode)
161
13x
    if cfg.Server.Debug {
162
13x
        gin.SetMode(gin.DebugMode)
163
13x
    }
164

165
    // Security middleware
166
13x
    secureConfig := secure.DefaultConfig()
167
13x
    secureConfig.SSLRedirect = false
168
13x
    secureConfig.ContentSecurityPolicy = config.DefaultCSP
169
13x
    router.Use(secure.New(secureConfig))
170
13x

171
13x
    // Serve all static assets (JS, fonts, CSS, etc.) from /backend/*filepath
172
13x
    // Note: Static assets are now served from the frontend build
173
13x

174
13x
    // Initialize handlers
175
13x
    authHandler := NewAuthHandler(userService, oauthService, cfg, logger)
176
13x
    authAPIKeyHandler := NewAuthAPIKeyHandler(authAPIKeyService, logger)
177
13x
    emailService := services.CreateEmailService(cfg, logger)
178
13x
    settingsHandler := NewSettingsHandler(userService, storyService, conversationService, aiService, learningService, emailService, usageStatsService, cfg, logger)
179
13x
    quizHandler := NewQuizHandler(userService, questionService, aiService, learningService, workerService, generationHintService, usageStatsService, cfg, logger)
180
13x
    dailyQuestionHandler := NewDailyQuestionHandler(userService, dailyQuestionService, cfg, logger)
181
13x
    storyHandler := NewStoryHandler(storyService, userService, aiService, cfg, logger)
182
13x
    aiConversationHandler := NewAIConversationHandler(conversationService, cfg, logger)
183
13x
    translationHandler := NewTranslationHandler(translationService, cfg, logger)
184
13x
    snippetsHandler := NewSnippetsHandler(snippetsService, cfg, logger)
185
13x
    wordOfTheDayHandler := NewWordOfTheDayHandler(userService, wordOfTheDayService, cfg, logger)
186
13x
    adminHandler := NewAdminHandlerWithLogger(userService, questionService, aiService, cfg, learningService, workerService, logger, usageStatsService)
187
13x
    // Inject story service into admin handler via exported field
188
13x
    adminHandler.storyService = storyService
189
13x
    userAdminHandler := NewUserAdminHandler(userService, cfg, logger)
190
13x
    verbConjugationHandler := NewVerbConjugationHandler(logger)
191
13x
    feedbackService := services.NewFeedbackService(userService.GetDB(), logger)
192
13x

193
13x
    // Initialize Linear service if enabled
194
13x
    var linearService *services.LinearService
195
13x
    if cfg.Linear.Enabled {
196
13x
        linearService = services.NewLinearService(cfg, logger)
197
13x
    }
198

199
13x
    feedbackHandler := NewFeedbackHandler(feedbackService, linearService, userService, cfg, logger)
200
13x

201
13x
    // V1 routes (matching swagger spec)
202
13x
    v1 := router.Group("/v1")
203
13x
    {
204
13x
        // Version aggregation endpoint (no auth)
205
13x
        v1.GET("/version", func(c *gin.Context) {
206
2x
            backendVersion := gin.H{
207
2x
                "service":   "backend",
208
2x
                "version":   version.Version,
209
2x
                "commit":    version.Commit,
210
2x
                "buildTime": version.BuildTime,
211
2x
            }
212
2x
            workerInternalURL := os.Getenv("WORKER_INTERNAL_URL")
213
2x
            if workerInternalURL == "" {
214
2x
                workerInternalURL = cfg.Server.WorkerInternalURL // fallback
215
2x
            }
216
            // Use instrumented HTTP client for tracing
217
2x
            client := &http.Client{
218
2x
                Transport: otelhttp.NewTransport(http.DefaultTransport),
219
2x
            }
220
2x
            req, err := http.NewRequest("GET", workerInternalURL+"/v1/version", nil)
221
2x
            var workerResp *http.Response
222
2x
            if err == nil {
223
2x
                req = req.WithContext(c.Request.Context())
224
2x
                workerResp, err = client.Do(req)
225
2x
            }
226
2x
            var workerVersion interface{}
227
2x
            if err == nil && workerResp.StatusCode == http.StatusOK {
228
                defer func() { _ = workerResp.Body.Close() }()
229
                if err := json.NewDecoder(workerResp.Body).Decode(&workerVersion); err != nil {
230
                    workerVersion = gin.H{"error": "Failed to decode worker version"}
231
                }
232
2x
            } else {
233
2x
                workerVersion = gin.H{"error": "Worker unavailable"}
234
2x
            }
235
2x
            c.JSON(http.StatusOK, gin.H{
236
2x
                "backend": backendVersion,
237
2x
                "worker":  workerVersion,
238
2x
            })
239
        })
240
13x
        auth := v1.Group("/auth")
241
13x
        {
242
13x
            auth.POST("/login", middleware.RequestValidationMiddleware(logger), authHandler.Login)
243
13x
            auth.POST("/logout", authHandler.Logout)
244
13x
            auth.GET("/status", authHandler.Status)
245
13x
            auth.GET("/check", middleware.RequireAuth(), authHandler.Check)
246
13x
            auth.POST("/signup", middleware.RequestValidationMiddleware(logger), authHandler.Signup)
247
13x
            auth.GET("/signup/status", authHandler.SignupStatus)
248
13x
            auth.GET("/google/login", authHandler.GoogleLogin)
249
13x
            auth.GET("/google/callback", authHandler.GoogleCallback)
250
13x
        }
251

252
        // API Keys routes (for programmatic API access)
253
13x
        apiKeys := v1.Group("/api-keys")
254
13x
        apiKeys.Use(middleware.RequireAuth()) // Keep session-only auth for managing API keys
255
13x
        {
256
13x
            apiKeys.POST("", middleware.RequestValidationMiddleware(logger), authAPIKeyHandler.CreateAPIKey)
257
13x
            apiKeys.GET("", authAPIKeyHandler.ListAPIKeys)
258
13x
            apiKeys.DELETE("/:id", authAPIKeyHandler.DeleteAPIKey)
259
13x
        }
260

261
        // API Key test endpoints using API key auth (no cookies)
262
13x
        apiKeysTest := v1.Group("/api-keys")
263
13x
        apiKeysTest.Use(middleware.RequireAuthWithAPIKey(authAPIKeyService, userService))
264
13x
        {
265
13x
            apiKeysTest.GET("/test-read", authAPIKeyHandler.TestRead)
266
13x
            apiKeysTest.POST("/test-write", authAPIKeyHandler.TestWrite)
267
13x
        }
268

269
        // Translation routes
270
13x
        v1.POST("/translate", middleware.RequireAuthWithAPIKey(authAPIKeyService, userService), translationHandler.TranslateText)
271
13x

272
13x
        // Feedback routes
273
13x
        v1.POST("/feedback", middleware.RequireAuthWithAPIKey(authAPIKeyService, userService), feedbackHandler.SubmitFeedback)
274
13x

275
13x
        // Snippets routes
276
13x
        v1.POST("/snippets", middleware.RequireAuthWithAPIKey(authAPIKeyService, userService), middleware.RequestValidationMiddleware(logger), snippetsHandler.CreateSnippet)
277
13x
        v1.GET("/snippets", middleware.RequireAuthWithAPIKey(authAPIKeyService, userService), snippetsHandler.GetSnippets)
278
13x
        v1.DELETE("/snippets", middleware.RequireAuthWithAPIKey(authAPIKeyService, userService), snippetsHandler.DeleteAllSnippets)
279
13x
        v1.GET("/snippets/search", middleware.RequireAuthWithAPIKey(authAPIKeyService, userService), snippetsHandler.SearchSnippets)
280
13x
        v1.GET("/snippets/by-question/:question_id", middleware.RequireAuthWithAPIKey(authAPIKeyService, userService), snippetsHandler.GetSnippetsByQuestion)
281
13x
        v1.GET("/snippets/by-section/:section_id", middleware.RequireAuthWithAPIKey(authAPIKeyService, userService), snippetsHandler.GetSnippetsBySection)
282
13x
        v1.GET("/snippets/by-story/:story_id", middleware.RequireAuthWithAPIKey(authAPIKeyService, userService), snippetsHandler.GetSnippetsByStory)
283
13x
        v1.GET("/snippets/:id", middleware.RequireAuthWithAPIKey(authAPIKeyService, userService), snippetsHandler.GetSnippet)
284
13x
        v1.PUT("/snippets/:id", middleware.RequireAuthWithAPIKey(authAPIKeyService, userService), middleware.RequestValidationMiddleware(logger), snippetsHandler.UpdateSnippet)
285
13x
        v1.DELETE("/snippets/:id", middleware.RequireAuthWithAPIKey(authAPIKeyService, userService), snippetsHandler.DeleteSnippet)
286
13x

287
13x
        quiz := v1.Group("/quiz")
288
13x
        quiz.Use(middleware.RequireAuthWithAPIKey(authAPIKeyService, userService))
289
13x
        quiz.Use(middleware.RequestValidationMiddleware(logger))
290
13x
        {
291
13x
            quiz.GET("/question", quizHandler.GetQuestion)
292
13x
            quiz.GET("/question/:id", quizHandler.GetQuestion)
293
13x
            quiz.POST("/question/:id/report", quizHandler.ReportQuestion)
294
13x
            quiz.POST("/question/:id/mark-known", quizHandler.MarkQuestionAsKnown)
295
13x
            quiz.POST("/answer", quizHandler.SubmitAnswer)
296
13x
            quiz.GET("/progress", quizHandler.GetProgress)
297
13x
            quiz.GET("/ai-token-usage", quizHandler.GetAITokenUsage)
298
13x
            quiz.GET("/ai-token-usage/daily", quizHandler.GetAITokenUsageDaily)
299
13x
            quiz.GET("/ai-token-usage/hourly", quizHandler.GetAITokenUsageHourly)
300
13x
            quiz.GET("/worker-status", quizHandler.GetWorkerStatus)
301
13x
            quiz.POST("/chat/stream", quizHandler.ChatStream)
302
13x
        }
303
13x
        daily := v1.Group("/daily")
304
13x
        daily.Use(middleware.RequireAuthWithAPIKey(authAPIKeyService, userService))
305
13x
        daily.Use(middleware.RequestValidationMiddleware(logger))
306
13x
        {
307
13x
            daily.GET("/questions/:date", dailyQuestionHandler.GetDailyQuestions)
308
13x
            daily.POST("/questions/:date/complete/:questionId", dailyQuestionHandler.MarkQuestionCompleted)
309
13x
            daily.DELETE("/questions/:date/complete/:questionId", dailyQuestionHandler.ResetQuestionCompleted)
310
13x
            daily.POST("/questions/:date/answer/:questionId", dailyQuestionHandler.SubmitDailyQuestionAnswer)
311
13x
            daily.GET("/history/:questionId", dailyQuestionHandler.GetQuestionHistory)
312
13x
            daily.GET("/dates", dailyQuestionHandler.GetAvailableDates)
313
13x
            daily.GET("/progress/:date", dailyQuestionHandler.GetDailyProgress)
314
13x
            // Note: Assignment is handled automatically by the worker
315
13x
        }
316

317
13x
        wordOfDay := v1.Group("/word-of-day")
318
13x
        {
319
13x
            // Protected endpoints requiring authentication (API key or session)
320
13x
            wordOfDay.GET("", middleware.RequireAuthWithAPIKey(authAPIKeyService, userService), wordOfTheDayHandler.GetWordOfTheDayToday)
321
13x
            wordOfDay.GET("/history", middleware.RequireAuthWithAPIKey(authAPIKeyService, userService), wordOfTheDayHandler.GetWordOfTheDayHistory)
322
13x
            // Embed endpoint supports optional date query parameter and requires API key or session auth
323
13x
            wordOfDay.GET("/embed", middleware.RequireAuthWithAPIKey(authAPIKeyService, userService), wordOfTheDayHandler.GetWordOfTheDayEmbed)
324
13x
            wordOfDay.GET("/:date/embed", middleware.RequireAuthWithAPIKey(authAPIKeyService, userService), wordOfTheDayHandler.GetWordOfTheDayEmbed)
325
13x
            wordOfDay.GET("/:date", middleware.RequireAuthWithAPIKey(authAPIKeyService, userService), wordOfTheDayHandler.GetWordOfTheDay)
326
13x
        }
327

328
13x
        story := v1.Group("/story")
329
13x
        story.Use(middleware.RequireAuthWithAPIKey(authAPIKeyService, userService))
330
13x
        story.Use(middleware.RequestValidationMiddleware(logger))
331
13x
        {
332
13x
            story.POST("", storyHandler.CreateStory)
333
13x
            story.GET("", storyHandler.GetUserStories)
334
13x
            story.GET("/current", storyHandler.GetCurrentStory)
335
13x
            story.GET("/:id", storyHandler.GetStory)
336
13x
            story.GET("/section/:id", storyHandler.GetSection)
337
13x
            story.POST("/:id/generate", storyHandler.GenerateNextSection)
338
13x
            story.POST("/:id/archive", storyHandler.ArchiveStory)
339
13x
            story.POST("/:id/complete", storyHandler.CompleteStory)
340
13x
            story.POST("/:id/set-current", storyHandler.SetCurrentStory)
341
13x
            story.POST("/:id/toggle-auto-generation", storyHandler.ToggleAutoGeneration)
342
13x
            story.DELETE("/:id", storyHandler.DeleteStory)
343
13x
            story.GET("/:id/export", storyHandler.ExportStory)
344
13x
        }
345
13x
        settings := v1.Group("/settings")
346
13x
        {
347
13x
            settings.GET("/ai-providers", middleware.RequireAuthWithAPIKey(authAPIKeyService, userService), settingsHandler.GetProviders)
348
13x
            settings.GET("/levels", settingsHandler.GetLevels)
349
13x
            settings.GET("/languages", settingsHandler.GetLanguages)
350
13x
            settings.POST("/test-ai", middleware.RequireAuthWithAPIKey(authAPIKeyService, userService), middleware.RequestValidationMiddleware(logger), settingsHandler.TestAIConnection)
351
13x
            settings.POST("/test-email", middleware.RequireAuthWithAPIKey(authAPIKeyService, userService), middleware.RequestValidationMiddleware(logger), settingsHandler.SendTestEmail)
352
13x
            settings.PUT("", middleware.RequireAuthWithAPIKey(authAPIKeyService, userService), middleware.RequestValidationMiddleware(logger), settingsHandler.UpdateUserSettings)
353
13x
            settings.PUT("/word-of-day-email", middleware.RequireAuthWithAPIKey(authAPIKeyService, userService), middleware.RequestValidationMiddleware(logger), settingsHandler.UpdateWordOfDayEmailPreference)
354
13x
            // User data management endpoints
355
13x
            settings.POST("/clear-stories", middleware.RequireAuthWithAPIKey(authAPIKeyService, userService), middleware.RequestValidationMiddleware(logger), settingsHandler.ClearAllStories)
356
13x
            settings.POST("/clear-ai-chats", middleware.RequireAuthWithAPIKey(authAPIKeyService, userService), middleware.RequestValidationMiddleware(logger), settingsHandler.ClearAllAIChats)
357
13x
            settings.POST("/reset-account", middleware.RequireAuthWithAPIKey(authAPIKeyService, userService), middleware.RequestValidationMiddleware(logger), settingsHandler.ResetAccount)
358
13x
            settings.GET("/api-key/:provider", middleware.RequireAuthWithAPIKey(authAPIKeyService, userService), settingsHandler.CheckAPIKeyAvailability)
359
13x
        }
360

361
        // Verb conjugation endpoints
362
13x
        verbConjugations := v1.Group("/verb-conjugations")
363
13x
        verbConjugations.Use(middleware.RequireAuthWithAPIKey(authAPIKeyService, userService))
364
13x
        {
365
13x
            verbConjugations.GET("/info", verbConjugationHandler.GetVerbConjugationInfo)
366
13x
            verbConjugations.GET("/languages", verbConjugationHandler.GetAvailableLanguages)
367
13x
            verbConjugations.GET("/:language", verbConjugationHandler.GetVerbConjugations)
368
13x
            verbConjugations.GET("/:language/:verb", verbConjugationHandler.GetVerbConjugation)
369
13x
        }
370

371
        // AI conversation endpoints
372
13x
        ai := v1.Group("/ai")
373
13x
        ai.Use(middleware.RequireAuthWithAPIKey(authAPIKeyService, userService))
374
13x
        ai.Use(middleware.RequestValidationMiddleware(logger))
375
13x
        {
376
13x
            ai.GET("/conversations", aiConversationHandler.GetConversations)
377
13x
            ai.POST("/conversations", aiConversationHandler.CreateConversation)
378
13x
            ai.GET("/conversations/:id", aiConversationHandler.GetConversation)
379
13x
            ai.PUT("/conversations/:id", aiConversationHandler.UpdateConversation)
380
13x
            ai.DELETE("/conversations/:id", aiConversationHandler.DeleteConversation)
381
13x
            ai.POST("/conversations/:conversationId/messages", aiConversationHandler.AddMessage)
382
13x
            ai.PUT("/conversations/bookmark", aiConversationHandler.ToggleMessageBookmark)
383
13x
            ai.GET("/search", aiConversationHandler.SearchConversations)
384
13x
            ai.GET("/bookmarks", aiConversationHandler.GetBookmarkedMessages)
385
13x
        }
386
13x
        preferences := v1.Group("/preferences")
387
13x
        preferences.Use(middleware.RequireAuthWithAPIKey(authAPIKeyService, userService))
388
13x
        preferences.Use(middleware.RequestValidationMiddleware(logger))
389
13x
        {
390
13x
            preferences.GET("/learning", settingsHandler.GetLearningPreferences)
391
13x
            preferences.PUT("/learning", settingsHandler.UpdateLearningPreferences)
392
13x
        }
393

394
        // User management endpoints (non-admin)
395
13x
        userz := v1.Group("/userz")
396
13x
        {
397
13x
            userz.PUT("/profile", middleware.RequireAuthWithAPIKey(authAPIKeyService, userService), middleware.RequestValidationMiddleware(logger), userAdminHandler.UpdateCurrentUserProfile)
398
13x
        }
399

400
        // Admin endpoints
401
13x
        admin := v1.Group("/admin")
402
13x
        admin.Use(middleware.RequireAdmin(userService))
403
13x
        admin.Use(middleware.RequestValidationMiddleware(logger))
404
13x
        {
405
13x
            // Backend admin endpoints
406
13x
            backend := admin.Group("/backend")
407
13x
            {
408
13x
                // Backend admin page
409
13x
                backend.GET("", adminHandler.GetBackendAdminPage)
410
13x
                // Feedback management (admin only)
411
13x
                backend.GET("/feedback", feedbackHandler.ListFeedback)
412
13x
                backend.GET("/feedback/:id", feedbackHandler.GetFeedback)
413
13x
                backend.PATCH("/feedback/:id", feedbackHandler.UpdateFeedback)
414
13x
                backend.DELETE("/feedback/:id", feedbackHandler.DeleteFeedback)
415
13x
                backend.DELETE("/feedback", func(c *gin.Context) {
416
2x
                    // Check if it's a delete all request
417
2x
                    if c.Query("all") == "true" {
418
                        feedbackHandler.DeleteAllFeedback(c)
419
                    } else {
420
2x
                        feedbackHandler.DeleteFeedbackByStatus(c)
421
2x
                    }
422
                })
423
13x
                backend.POST("/feedback/:id/linear-issue", feedbackHandler.CreateLinearIssue)
424
13x
                // User management (admin only)
425
13x
                backend.GET("/userz", userAdminHandler.GetAllUsers)
426
13x
                backend.GET("/userz/paginated", userAdminHandler.GetUsersPaginated)
427
13x
                backend.POST("/userz", userAdminHandler.CreateUser)
428
13x
                backend.PUT("/userz/:id", userAdminHandler.UpdateUser)
429
13x
                backend.DELETE("/userz/:id", userAdminHandler.DeleteUser)
430
13x
                backend.POST("/userz/:id/reset-password", userAdminHandler.ResetUserPassword)
431
13x

432
13x
                // Role management endpoints
433
13x
                backend.GET("/roles", adminHandler.GetRoles)
434
13x
                backend.GET("/userz/:id/roles", adminHandler.GetUserRoles)
435
13x
                backend.POST("/userz/:id/roles", adminHandler.AssignRole)
436
13x
                backend.DELETE("/userz/:id/roles/:roleId", adminHandler.RemoveRole)
437
13x

438
13x
                // Admin dashboard data
439
13x
                backend.GET("/dashboard", adminHandler.GetBackendAdminData)
440
13x
                backend.GET("/ai-concurrency", adminHandler.GetAIConcurrencyStats)
441
13x

442
13x
                // Question management
443
13x
                backend.GET("/questions/:id", adminHandler.GetQuestion)
444
13x
                backend.GET("/questions/:id/users", adminHandler.GetUsersForQuestion)
445
13x
                backend.PUT("/questions/:id", adminHandler.UpdateQuestion)
446
13x
                backend.DELETE("/questions/:id", adminHandler.DeleteQuestion)
447
13x
                backend.POST("/questions/:id/assign-users", adminHandler.AssignUsersToQuestion)
448
13x
                backend.POST("/questions/:id/unassign-users", adminHandler.UnassignUsersFromQuestion)
449
13x
                backend.GET("/questions/paginated", adminHandler.GetQuestionsPaginated)
450
13x
                backend.GET("/questions", adminHandler.GetAllQuestions)
451
13x
                backend.GET("/reported-questions", adminHandler.GetReportedQuestionsPaginated)
452
13x
                backend.POST("/questions/:id/fix", adminHandler.MarkQuestionAsFixed)
453
13x
                backend.POST("/questions/:id/ai-fix", adminHandler.FixQuestionWithAI)
454
13x

455
13x
                // Data management
456
13x
                backend.POST("/clear-user-data", adminHandler.ClearUserData)
457
13x
                backend.POST("/clear-database", adminHandler.ClearDatabase)
458
13x
                backend.POST("/userz/:id/clear", adminHandler.ClearUserDataForUser)
459
13x

460
13x
                // Story explorer (admin)
461
13x
                backend.GET("/stories", adminHandler.GetStoriesPaginated)
462
13x
                backend.GET("/stories/:id", adminHandler.GetStoryAdmin)
463
13x
                backend.DELETE("/stories/:id", adminHandler.DeleteStoryAdmin)
464
13x
                backend.GET("/story-sections/:id", adminHandler.GetSectionAdmin)
465
13x

466
13x
                // Usage stats (admin)
467
13x
                backend.GET("/usage-stats", adminHandler.GetUsageStats)
468
13x
                backend.GET("/usage-stats/:service", adminHandler.GetUsageStatsByService)
469
            }
470

471
        }
472
    }
473

474
    // Config dump endpoint
475
13x
    router.GET("/configz", adminHandler.GetConfigz)
476
13x

477
13x
    // Serve frontend static files
478
13x
    router.Static("/assets", "./frontend/dist/assets")
479
13x
    router.StaticFile("/favicon.svg", "./frontend/dist/favicon.svg")
480
13x
    router.StaticFile("/fonts", "./frontend/dist/fonts")
481
13x

482
13x
    // Catch-all route for SPA - serve index.html for any route that doesn't match API routes
483
13x
    router.NoRoute(func(c *gin.Context) {
484
11x
        // Don't serve index.html for API routes
485
11x
        if strings.HasPrefix(c.Request.URL.Path, "/v1/") ||
486
11x
            strings.HasPrefix(c.Request.URL.Path, "/configz") ||
487
11x
            strings.HasPrefix(c.Request.URL.Path, "/swagger") ||
488
11x
            strings.HasPrefix(c.Request.URL.Path, "/backend/") {
489
11x
            c.JSON(http.StatusNotFound, gin.H{"error": "Not found"})
490
11x
            return
491
11x
        }
492

493
        // Serve the frontend's index.html for all other routes
494
        c.File("./frontend/dist/index.html")
495
    })
496

497
    // Automatic route listing at root path
498
13x
    routeListing := NewRouteListingHandler("Backend")
499
13x
    routeListing.CollectRoutes(router)
500
13x

501
13x
    // Root path shows all available routes
502
13x
    router.GET("/", func(c *gin.Context) {
503
1x
        if c.Query("json") == "true" {
504
1x
            routeListing.GetRouteListingJSON(c)
505
1x
        } else {
506
            routeListing.GetRouteListingPage(c)
507
        }
508
    })
509

510
13x
    return router
511
}
512


			
quizapp internal handlers worker_admin_handler.go
61.5%
Statements
16/26
1
package handlers
2

3
import (
4
    "quizapp/internal/middleware"
5

6
    "github.com/gin-contrib/sessions"
7
    "github.com/gin-gonic/gin"
8
)
9

10
// GetUserIDFromSession retrieves the current user ID from the session or context.
11
// Returns (0, false) if not authenticated or if the stored value is invalid.
12
182x
func GetUserIDFromSession(c *gin.Context) (int, bool) {
13
182x
    // First check if user ID is already in context (set by auth middleware)
14
182x
    if userIDVal, exists := c.Get(middleware.UserIDKey); exists {
15
147x
        if id, ok := userIDVal.(int); ok {
16
131x
            return id, true
17
131x
        }
18
        // Try to convert from uint (common in tests)
19
16x
        if idUint, ok := userIDVal.(uint); ok {
20
16x
            return int(idUint), true
21
16x
        }
22
        // If it's some other type in context, it's invalid
23
        return 0, false
24
    }
25

26
    // Fall back to session if not in context (maintain original behavior for sessions)
27
35x
    session := sessions.Default(c)
28
35x
    userID := session.Get(middleware.UserIDKey)
29
35x
    if userID == nil {
30
1x
        return 0, false
31
1x
    }
32
33x
    id, ok := userID.(int)
33
33x
    if !ok {
34
1x
        return 0, false
35
1x
    }
36
31x
    return id, true
37
}
38

39
// GetUsernameFromSession retrieves the current user username from the session or context.
40
// Returns (0, false) if not authenticated or if the stored value is invalid.
41
7x
func GetUsernameFromSession(c *gin.Context) (string, bool) {
42
7x
    // First check if user ID is already in context (set by auth middleware)
43
7x
    if usernameVal, exists := c.Get(middleware.UsernameKey); exists {
44
7x
        if username, ok := usernameVal.(string); ok {
45
7x
            return username, true
46
7x
        }
47
        return "", false
48
    }
49

50
    // Fall back to session if not in context (maintain original behavior for sessions)
51
    session := sessions.Default(c)
52
    username := session.Get(middleware.UsernameKey)
53
    if username == nil {
54
        return "", false
55
    }
56
    usernameStr, ok := username.(string)
57
    if !ok {
58
        return "", false
59
    }
60
    return usernameStr, true
61
}
62


			
quizapp internal handlers worker_admin_handler.go
70.7%
Statements
181/256
1
package handlers
2

3
import (
4
    "fmt"
5
    "net/http"
6

7
    "quizapp/internal/api"
8
    "quizapp/internal/config"
9
    "quizapp/internal/middleware"
10
    "quizapp/internal/models"
11
    "quizapp/internal/observability"
12
    "quizapp/internal/services"
13
    "quizapp/internal/services/mailer"
14
    contextutils "quizapp/internal/utils"
15

16
    "github.com/gin-contrib/sessions"
17
    "github.com/gin-gonic/gin"
18
    "go.opentelemetry.io/otel/attribute"
19
)
20

21
// SettingsHandler handles user settings related HTTP requests
22
type SettingsHandler struct {
23
    userService         services.UserServiceInterface
24
    storyService        services.StoryServiceInterface
25
    conversationService services.ConversationServiceInterface
26
    aiService           services.AIServiceInterface
27
    learningService     services.LearningServiceInterface
28
    usageStatsSvc       services.UsageStatsServiceInterface
29
    emailService        mailer.Mailer
30
    cfg                 *config.Config
31
    logger              *observability.Logger
32
}
33

34
// NewSettingsHandler creates a new SettingsHandler instance
35
28x
func NewSettingsHandler(userService services.UserServiceInterface, storyService services.StoryServiceInterface, conversationService services.ConversationServiceInterface, aiService services.AIServiceInterface, learningService services.LearningServiceInterface, emailService mailer.Mailer, usageStatsSvc services.UsageStatsServiceInterface, cfg *config.Config, logger *observability.Logger) *SettingsHandler {
36
28x
    return &SettingsHandler{
37
28x
        userService:         userService,
38
28x
        storyService:        storyService,
39
28x
        conversationService: conversationService,
40
28x
        aiService:           aiService,
41
28x
        learningService:     learningService,
42
28x
        usageStatsSvc:       usageStatsSvc,
43
28x
        emailService:        emailService,
44
28x
        cfg:                 cfg,
45
28x
        logger:              logger,
46
28x
    }
47
28x
}
48

49
// UpdateWordOfDayEmailPreference updates the user's word-of-day email preference
50
2x
func (h *SettingsHandler) UpdateWordOfDayEmailPreference(c *gin.Context) {
51
2x
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "update_word_of_day_email_preference")
52
2x
    defer observability.FinishSpan(span, nil)
53
2x

54
2x
    session := sessions.Default(c)
55
2x
    userID, ok := session.Get(middleware.UserIDKey).(int)
56
2x
    if !ok {
57
        HandleAppError(c, contextutils.ErrUnauthorized)
58
        return
59
    }
60

61
2x
    var body struct {
62
2x
        Enabled bool `json:"enabled"`
63
2x
    }
64
2x
    if err := c.ShouldBindJSON(&body); err != nil {
65
        HandleAppError(c, contextutils.NewAppErrorWithCause(
66
            contextutils.ErrorCodeInvalidInput,
67
            contextutils.SeverityWarn,
68
            "Invalid request body",
69
            "",
70
            err,
71
        ))
72
        return
73
    }
74

75
2x
    if err := h.userService.UpdateWordOfDayEmailEnabled(ctx, userID, body.Enabled); err != nil {
76
        HandleAppError(c, contextutils.WrapError(err, "failed to update word of day email preference"))
77
        return
78
    }
79

80
2x
    c.JSON(http.StatusOK, gin.H{"success": true})
81
}
82

83
// UpdateUserSettings handles updating user settings
84
16x
func (h *SettingsHandler) UpdateUserSettings(c *gin.Context) {
85
16x
    _, span := observability.TraceHandlerFunction(c.Request.Context(), "update_user_settings")
86
16x
    defer observability.FinishSpan(span, nil)
87
16x
    session := sessions.Default(c)
88
16x
    userID, ok := session.Get(middleware.UserIDKey).(int)
89
16x
    if !ok {
90
        HandleAppError(c, contextutils.ErrUnauthorized)
91
        return
92
    }
93

94
16x
    var settings api.UserSettings
95
16x
    if err := c.ShouldBindJSON(&settings); err != nil {
96
1x
        HandleAppError(c, contextutils.NewAppErrorWithCause(
97
1x
            contextutils.ErrorCodeInvalidInput,
98
1x
            contextutils.SeverityWarn,
99
1x
            "Invalid request body",
100
1x
            "",
101
1x
            err,
102
1x
        ))
103
1x
        return
104
1x
    }
105

106
    // Validate that at least one meaningful field is provided
107
    // Avoid relying on generated union/raw fields that may be non-nil for an empty JSON body
108
15x
    hasAnyField := settings.Language != nil ||
109
15x
        settings.Level != nil ||
110
15x
        settings.AiProvider != nil ||
111
15x
        settings.AiModel != nil ||
112
15x
        settings.ApiKey != nil ||
113
15x
        settings.AiEnabled != nil
114
15x

115
15x
    if !hasAnyField {
116
1x
        HandleAppError(c, contextutils.ErrInvalidInput)
117
1x
        return
118
1x
    }
119

120
    // Convert api.UserSettings to models.UserSettings
121
14x
    modelSettings := models.UserSettings{}
122
14x
    if settings.Language != nil {
123
14x
        modelSettings.Language = string(*settings.Language)
124
14x
        span.SetAttributes(attribute.String("settings.language", modelSettings.Language))
125
14x
    }
126
14x
    if settings.Level != nil {
127
13x
        modelSettings.Level = string(*settings.Level)
128
13x
        span.SetAttributes(attribute.String("settings.level", modelSettings.Level))
129
13x
    }
130
14x
    if settings.AiProvider != nil {
131
9x
        modelSettings.AIProvider = *settings.AiProvider
132
9x
        span.SetAttributes(attribute.String("settings.ai_provider", modelSettings.AIProvider))
133
9x
    }
134
14x
    if settings.AiModel != nil {
135
10x
        modelSettings.AIModel = *settings.AiModel
136
10x
        span.SetAttributes(attribute.String("settings.ai_model", modelSettings.AIModel))
137
10x
    }
138
14x
    if settings.ApiKey != nil {
139
10x
        modelSettings.AIAPIKey = *settings.ApiKey
140
10x
        span.SetAttributes(attribute.Bool("settings.api_key_provided", true))
141
10x
    }
142
14x
    if settings.AiEnabled != nil {
143
10x
        modelSettings.AIEnabled = *settings.AiEnabled
144
10x
        span.SetAttributes(attribute.Bool("settings.ai_enabled", modelSettings.AIEnabled))
145
10x
    }
146

147
    // Validate level if provided (including empty string)
148
14x
    if settings.Level != nil {
149
13x
        validLevels := h.cfg.GetAllLevels()
150
13x
        isValidLevel := false
151
13x
        for _, level := range validLevels {
152
85x
            if modelSettings.Level == level {
153
10x
                isValidLevel = true
154
10x
                break
155
            }
156
        }
157

158
13x
        if !isValidLevel {
159
3x
            HandleAppError(c, contextutils.ErrInvalidFormat)
160
3x
            return
161
3x
        }
162
    }
163

164
    // Validate language if provided (including empty string)
165
11x
    if settings.Language != nil {
166
11x
        validLanguages := h.cfg.GetLanguages()
167
11x
        isValidLanguage := false
168
11x
        for _, language := range validLanguages {
169
60x
            if modelSettings.Language == language {
170
10x
                isValidLanguage = true
171
10x
                break
172
            }
173
        }
174

175
11x
        if !isValidLanguage {
176
1x
            HandleAppError(c, contextutils.ErrInvalidFormat)
177
1x
            return
178
1x
        }
179
    }
180

181
10x
    if err := h.userService.UpdateUserSettings(c.Request.Context(), userID, &modelSettings); err != nil {
182
        // Check if the error is due to user not found
183
        if contextutils.IsError(err, contextutils.ErrRecordNotFound) {
184
            HandleAppError(c, contextutils.ErrRecordNotFound)
185
            return
186
        }
187
        HandleAppError(c, contextutils.WrapError(err, "failed to update settings"))
188
        return
189
    }
190

191
10x
    c.JSON(http.StatusOK, api.SuccessResponse{Success: true})
192
}
193

194
// TestAIConnection tests the AI service connection with provided settings
195
2x
func (h *SettingsHandler) TestAIConnection(c *gin.Context) {
196
2x
    _, span := observability.TraceHandlerFunction(c.Request.Context(), "test_ai_connection")
197
2x
    defer observability.FinishSpan(span, nil)
198
2x
    session := sessions.Default(c)
199
2x
    userID, ok := session.Get(middleware.UserIDKey).(int)
200
2x
    if !ok {
201
        HandleAppError(c, contextutils.ErrUnauthorized)
202
        return
203
    }
204

205
2x
    var req api.TestAIRequest
206
2x
    if err := c.ShouldBindJSON(&req); err != nil {
207
1x
        HandleAppError(c, contextutils.NewAppErrorWithCause(
208
1x
            contextutils.ErrorCodeInvalidInput,
209
1x
            contextutils.SeverityWarn,
210
1x
            "Invalid request format",
211
1x
            "",
212
1x
            err,
213
1x
        ))
214
1x
        return
215
1x
    }
216

217
    // Extract values from API request
218
1x
    provider := req.Provider
219
1x
    model := req.Model
220
1x
    apiKey := ""
221
1x
    if req.ApiKey != nil {
222
1x
        apiKey = *req.ApiKey
223
1x
    }
224

225
    // If API key is empty, try to use the saved one from the new user_api_keys table
226
1x
    if apiKey == "" {
227
        savedKey, err := h.userService.GetUserAPIKey(c.Request.Context(), userID, provider)
228
        if err != nil {
229
            HandleAppError(c, contextutils.WrapError(err, "failed to get saved API key"))
230
            return
231
        }
232
        apiKey = savedKey
233
    }
234

235
1x
    err := h.aiService.TestConnection(c.Request.Context(), provider, model, apiKey)
236
1x
    if err != nil {
237
1x
        c.JSON(http.StatusOK, gin.H{
238
1x
            "success": false,
239
1x
            "message": fmt.Sprintf("Model '%s': %s", model, err.Error()),
240
1x
        })
241
1x
        return
242
1x
    }
243

244
    c.JSON(http.StatusOK, gin.H{"success": true, "message": "Connection successful"})
245
}
246

247
// GetProviders returns the available AI provider configurations
248
3x
func (h *SettingsHandler) GetProviders(c *gin.Context) {
249
3x
    _, span := observability.TraceHandlerFunction(c.Request.Context(), "get_providers")
250
3x
    defer observability.FinishSpan(span, nil)
251
3x

252
3x
    response := gin.H{
253
3x
        "providers": h.cfg.Providers,
254
3x
        "levels":    h.cfg.GetAllLevels(),
255
3x
        "languages": h.cfg.GetLanguages(),
256
3x
    }
257
3x
    c.JSON(http.StatusOK, response)
258
3x
}
259

260
// GetLevels returns the available levels and their descriptions.
261
25x
func (h *SettingsHandler) GetLevels(c *gin.Context) {
262
25x
    _, span := observability.TraceHandlerFunction(c.Request.Context(), "get_levels")
263
25x
    defer observability.FinishSpan(span, nil)
264
25x
    language := c.Query("language")
265
25x
    if language != "" {
266
22x
        levels := h.cfg.GetLevelsForLanguage(language)
267
22x
        descriptions := h.cfg.GetLevelDescriptionsForLanguage(language)
268
22x
        c.JSON(http.StatusOK, gin.H{
269
22x
            "levels":             levels,
270
22x
            "level_descriptions": descriptions,
271
22x
        })
272
22x
        return
273
22x
    }
274
3x
    c.JSON(http.StatusOK, gin.H{
275
3x
        "levels":             h.cfg.GetAllLevels(),
276
3x
        "level_descriptions": h.cfg.GetAllLevelDescriptions(),
277
3x
    })
278
}
279

280
// GetLanguages returns the available languages.
281
3x
func (h *SettingsHandler) GetLanguages(c *gin.Context) {
282
3x
    _, span := observability.TraceHandlerFunction(c.Request.Context(), "get_languages")
283
3x
    defer observability.FinishSpan(span, nil)
284
3x
    c.JSON(http.StatusOK, h.cfg.GetLanguageInfoList())
285
3x
}
286

287
// CheckAPIKeyAvailability checks if the user has a saved API key for the specified provider
288
2x
func (h *SettingsHandler) CheckAPIKeyAvailability(c *gin.Context) {
289
2x
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "check_api_key_availability")
290
2x
    defer observability.FinishSpan(span, nil)
291
2x
    session := sessions.Default(c)
292
2x
    userID, ok := session.Get(middleware.UserIDKey).(int)
293
2x
    if !ok {
294
        HandleAppError(c, contextutils.ErrUnauthorized)
295
        return
296
    }
297

298
2x
    provider := c.Param("provider")
299
2x
    if provider == "" {
300
        HandleAppError(c, contextutils.ErrMissingRequired)
301
        return
302
    }
303

304
    // Check if user has a saved API key for this provider
305
2x
    hasAPIKey, err := h.userService.HasUserAPIKey(ctx, userID, provider)
306
2x
    if err != nil {
307
        h.logger.Error(ctx, "Failed to check API key availability", err, map[string]interface{}{
308
            "user_id":  userID,
309
            "provider": provider,
310
        })
311
        HandleAppError(c, contextutils.WrapError(err, "failed to check API key availability"))
312
        return
313
    }
314

315
2x
    c.JSON(http.StatusOK, gin.H{"has_api_key": hasAPIKey})
316
}
317

318
// GetLearningPreferences retrieves user learning preferences
319
5x
func (h *SettingsHandler) GetLearningPreferences(c *gin.Context) {
320
5x
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "get_learning_preferences")
321
5x
    defer observability.FinishSpan(span, nil)
322
5x
    session := sessions.Default(c)
323
5x
    userID, ok := session.Get(middleware.UserIDKey).(int)
324
5x
    if !ok {
325
        HandleAppError(c, contextutils.ErrUnauthorized)
326
        return
327
    }
328

329
5x
    preferences, err := h.learningService.GetUserLearningPreferences(ctx, userID)
330
5x
    if err != nil {
331
1x
        h.logger.Error(ctx, "Failed to get learning preferences", err, map[string]interface{}{
332
1x
            "user_id": userID,
333
1x
        })
334
1x
        HandleAppError(c, contextutils.WrapError(err, "failed to get learning preferences"))
335
1x
        return
336
1x
    }
337

338
    // Convert backend model to API schema
339
3x
    apiPreferences := convertLearningPreferencesToAPI(preferences)
340
3x
    c.JSON(http.StatusOK, apiPreferences)
341
}
342

343
// UpdateLearningPreferences updates user learning preferences
344
8x
func (h *SettingsHandler) UpdateLearningPreferences(c *gin.Context) {
345
8x
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "update_learning_preferences")
346
8x
    defer observability.FinishSpan(span, nil)
347
8x
    session := sessions.Default(c)
348
8x
    userID, ok := session.Get(middleware.UserIDKey).(int)
349
8x
    if !ok {
350
        HandleAppError(c, contextutils.ErrUnauthorized)
351
        return
352
    }
353

354
8x
    var req models.UserLearningPreferences
355
8x
    if err := c.ShouldBindJSON(&req); err != nil {
356
3x
        HandleAppError(c, contextutils.NewAppErrorWithCause(
357
3x
            contextutils.ErrorCodeInvalidInput,
358
3x
            contextutils.SeverityWarn,
359
3x
            "Invalid request body",
360
3x
            "",
361
3x
            err,
362
3x
        ))
363
3x
        return
364
3x
    }
365

366
    // Set the user ID
367
5x
    req.UserID = userID
368
5x

369
5x
    // Set span attributes for updated preferences
370
5x
    span.SetAttributes(
371
5x
        attribute.Bool("learning.focus_on_weak_areas", req.FocusOnWeakAreas),
372
5x
        attribute.Bool("learning.include_review_questions", req.IncludeReviewQuestions),
373
5x
        attribute.Float64("learning.fresh_question_ratio", req.FreshQuestionRatio),
374
5x
        attribute.Float64("learning.known_question_penalty", req.KnownQuestionPenalty),
375
5x
        attribute.Int("learning.review_interval_days", req.ReviewIntervalDays),
376
5x
        attribute.Float64("learning.weak_area_boost", req.WeakAreaBoost),
377
5x
    )
378
5x

379
5x
    // Update preferences in database
380
5x
    updatedPrefs, err := h.learningService.UpdateUserLearningPreferences(ctx, userID, &req)
381
5x
    if err != nil {
382
1x
        h.logger.Error(ctx, "Failed to update learning preferences", err, map[string]interface{}{
383
1x
            "user_id": userID,
384
1x
        })
385
1x
        HandleAppError(c, contextutils.WrapError(err, "failed to update learning preferences"))
386
1x
        return
387
1x
    }
388

389
    // Convert backend model to API schema and return
390
3x
    apiPreferences := convertLearningPreferencesToAPI(updatedPrefs)
391
3x
    c.JSON(http.StatusOK, apiPreferences)
392
}
393

394
// SendTestEmail sends a test email to the current user
395
1x
func (h *SettingsHandler) SendTestEmail(c *gin.Context) {
396
1x
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "send_test_email")
397
1x
    defer observability.FinishSpan(span, nil)
398
1x

399
1x
    session := sessions.Default(c)
400
1x
    userID, ok := session.Get(middleware.UserIDKey).(int)
401
1x
    if !ok {
402
        HandleAppError(c, contextutils.ErrUnauthorized)
403
        return
404
    }
405

406
    // Get the current user
407
1x
    user, err := h.userService.GetUserByID(ctx, userID)
408
1x
    if err != nil {
409
        h.logger.Error(ctx, "Failed to get user for test email", err, map[string]interface{}{
410
            "user_id": userID,
411
        })
412
        HandleAppError(c, contextutils.WrapError(err, "failed to get user information"))
413
        return
414
    }
415

416
    // Check if user has an email address
417
1x
    if !user.Email.Valid || user.Email.String == "" {
418
1x
        HandleAppError(c, contextutils.ErrMissingRequired)
419
1x
        return
420
1x
    }
421

422
    // Check if email service is enabled
423
    if !h.emailService.IsEnabled() {
424
        HandleAppError(c, contextutils.ErrServiceUnavailable)
425
        return
426
    }
427

428
    // Send test email
429
    err = h.emailService.SendEmail(ctx, user.Email.String, "Test Email from Quiz App", "test_email", map[string]interface{}{
430
        "Username": user.Username,
431
        "TestTime": "now",
432
        "Message":  "This is a test email to verify your email settings are working correctly.",
433
    })
434
    if err != nil {
435
        h.logger.Error(ctx, "Failed to send test email", err, map[string]interface{}{
436
            "user_id": userID,
437
            "email":   user.Email.String,
438
        })
439
        HandleAppError(c, contextutils.WrapError(err, "failed to send test email"))
440
        return
441
    }
442

443
    h.logger.Info(ctx, "Test email sent successfully", map[string]interface{}{
444
        "user_id": userID,
445
        "email":   user.Email.String,
446
    })
447

448
    c.JSON(http.StatusOK, api.SuccessResponse{Success: true, Message: stringPtr("Test email sent successfully")})
449
}
450

451
// ClearAllStories deletes all stories belonging to the current user
452
1x
func (h *SettingsHandler) ClearAllStories(c *gin.Context) {
453
1x
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "clear_all_stories")
454
1x
    defer observability.FinishSpan(span, nil)
455
1x
    session := sessions.Default(c)
456
1x
    userID, ok := session.Get(middleware.UserIDKey).(int)
457
1x
    if !ok {
458
        HandleAppError(c, contextutils.ErrUnauthorized)
459
        return
460
    }
461
    // Use the story service to delete all stories for this user
462
1x
    if h.storyService == nil {
463
        h.logger.Warn(ctx, "Story service not available for ClearAllStories")
464
        HandleAppError(c, contextutils.NewAppErrorWithCause(
465
            contextutils.ErrorCodeInvalidInput,
466
            contextutils.SeverityWarn,
467
            "Clear all stories not available",
468
            "",
469
            nil,
470
        ))
471
        return
472
    }
473

474
1x
    if err := h.storyService.DeleteAllStoriesForUser(ctx, uint(userID)); err != nil {
475
        h.logger.Error(ctx, "Failed to delete all stories for user", err, map[string]interface{}{"user_id": userID})
476
        HandleAppError(c, contextutils.WrapError(err, "failed to delete all stories for user"))
477
        return
478
    }
479

480
1x
    c.JSON(http.StatusOK, gin.H{"success": true, "message": "All stories deleted successfully"})
481
}
482

483
// ResetAccount deletes all stories and clears user-specific data (questions, stats)
484
1x
func (h *SettingsHandler) ResetAccount(c *gin.Context) {
485
1x
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "reset_account")
486
1x
    defer observability.FinishSpan(span, nil)
487
1x
    session := sessions.Default(c)
488
1x
    userID, ok := session.Get(middleware.UserIDKey).(int)
489
1x
    if !ok {
490
        HandleAppError(c, contextutils.ErrUnauthorized)
491
        return
492
    }
493
    // Reset account: clear user data (questions, responses, metrics) and delete stories
494
    // First, clear user data (uses userService)
495
1x
    if err := h.userService.ClearUserDataForUser(ctx, userID); err != nil {
496
        h.logger.Error(ctx, "Failed to clear user data for user during reset", err, map[string]interface{}{"user_id": userID})
497
        HandleAppError(c, contextutils.WrapError(err, "failed to clear user data"))
498
        return
499
    }
500

501
    // Then delete all stories
502
1x
    if h.storyService == nil {
503
        h.logger.Warn(ctx, "Story service not available for ResetAccount")
504
        HandleAppError(c, contextutils.NewAppErrorWithCause(
505
            contextutils.ErrorCodeInvalidInput,
506
            contextutils.SeverityWarn,
507
            "Reset account not available",
508
            "",
509
            nil,
510
        ))
511
        return
512
    }
513

514
1x
    if err := h.storyService.DeleteAllStoriesForUser(ctx, uint(userID)); err != nil {
515
        h.logger.Error(ctx, "Failed to delete stories during reset account", err, map[string]interface{}{"user_id": userID})
516
        HandleAppError(c, contextutils.WrapError(err, "failed to delete stories during reset"))
517
        return
518
    }
519

520
1x
    c.JSON(http.StatusOK, gin.H{"success": true, "message": "Account reset successfully"})
521
}
522

523
// ClearAllAIChats deletes all AI conversations and messages for the current user
524
1x
func (h *SettingsHandler) ClearAllAIChats(c *gin.Context) {
525
1x
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "clear_all_ai_chats")
526
1x
    defer observability.FinishSpan(span, nil)
527
1x
    session := sessions.Default(c)
528
1x
    userID, ok := session.Get(middleware.UserIDKey).(int)
529
1x
    if !ok {
530
        HandleAppError(c, contextutils.ErrUnauthorized)
531
        return
532
    }
533

534
    // Use the conversation service to delete all conversations for this user
535
1x
    if h.conversationService == nil {
536
        h.logger.Warn(ctx, "Conversation service not available for ClearAllAIChats")
537
        HandleAppError(c, contextutils.NewAppErrorWithCause(
538
            contextutils.ErrorCodeInvalidInput,
539
            contextutils.SeverityWarn,
540
            "Clear all AI chats not available",
541
            "",
542
            nil,
543
        ))
544
        return
545
    }
546

547
    // Get all conversation IDs for this user
548
1x
    conversations, _, err := h.conversationService.GetUserConversations(ctx, uint(userID), 1000, 0) // Get max 1000 to avoid issues
549
1x
    if err != nil {
550
        h.logger.Error(ctx, "Failed to get user conversations for deletion", err, map[string]interface{}{"user_id": userID})
551
        HandleAppError(c, contextutils.WrapError(err, "failed to get user conversations for deletion"))
552
        return
553
    }
554

555
    // Delete each conversation
556
1x
    deletedCount := 0
557
1x
    for _, conversation := range conversations {
558
2x
        err := h.conversationService.DeleteConversation(ctx, conversation.Id.String(), uint(userID))
559
2x
        if err != nil {
560
            h.logger.Error(ctx, "Failed to delete conversation", err, map[string]interface{}{
561
                "user_id":         userID,
562
                "conversation_id": conversation.Id.String(),
563
            })
564
            // Continue with other conversations even if one fails
565
        } else {
566
2x
            deletedCount++
567
2x
        }
568
    }
569

570
1x
    h.logger.Info(ctx, "Deleted AI conversations for user", map[string]interface{}{
571
1x
        "user_id":       userID,
572
1x
        "deleted_count": deletedCount,
573
1x
        "total_count":   len(conversations),
574
1x
    })
575
1x

576
1x
    c.JSON(http.StatusOK, api.SuccessResponse{
577
1x
        Message: stringPtr(fmt.Sprintf("Deleted %d AI conversations successfully", deletedCount)),
578
1x
        Success: true,
579
1x
    })
580
}
581


			
quizapp internal handlers worker_admin_handler.go
10.2%
Statements
34/332
1
package handlers
2

3
import (
4
    "net/http"
5
    "strconv"
6

7
    "quizapp/internal/api"
8
    "quizapp/internal/config"
9
    "quizapp/internal/observability"
10
    "quizapp/internal/services"
11
    contextutils "quizapp/internal/utils"
12

13
    "github.com/gin-gonic/gin"
14
    "go.opentelemetry.io/otel/attribute"
15
)
16

17
// SnippetsHandler handles snippets related HTTP requests
18
type SnippetsHandler struct {
19
    snippetsService services.SnippetsServiceInterface
20
    cfg             *config.Config
21
    logger          *observability.Logger
22
}
23

24
// NewSnippetsHandler creates a new SnippetsHandler instance
25
14x
func NewSnippetsHandler(snippetsService services.SnippetsServiceInterface, cfg *config.Config, logger *observability.Logger) *SnippetsHandler {
26
14x
    return &SnippetsHandler{
27
14x
        snippetsService: snippetsService,
28
14x
        cfg:             cfg,
29
14x
        logger:          logger,
30
14x
    }
31
14x
}
32

33
// CreateSnippet handles POST /v1/snippets
34
func (h *SnippetsHandler) CreateSnippet(c *gin.Context) {
35
    ctx, span := observability.TraceSnippetFunction(c.Request.Context(), "create_snippet")
36
    defer observability.FinishSpan(span, nil)
37

38
    // Get user ID from context (set by auth middleware)
39
    userID, exists := GetUserIDFromSession(c)
40
    if !exists {
41
        h.logger.Warn(ctx, "User ID not found in context")
42
        HandleAppError(c, contextutils.ErrUnauthorized)
43
        return
44
    }
45
    username, exists := GetUsernameFromSession(c)
46
    if !exists {
47
        h.logger.Warn(ctx, "Username not found in context")
48
        HandleAppError(c, contextutils.ErrUnauthorized)
49
        return
50
    }
51
    span.SetAttributes(attribute.Int64("user.id", int64(userID)))
52
    span.SetAttributes(attribute.String("user.username", username))
53

54
    var req api.CreateSnippetRequest
55
    if err := c.ShouldBindJSON(&req); err != nil {
56
        h.logger.Warn(ctx, "Invalid create snippet request format", map[string]interface{}{
57
            "error": err.Error(),
58
        })
59
        HandleAppError(c, contextutils.ErrInvalidInput)
60
        return
61
    }
62

63
    snippet, err := h.snippetsService.CreateSnippet(ctx, int64(userID), req)
64
    if err != nil {
65
        h.logger.Error(ctx, "Failed to create snippet", err, map[string]interface{}{
66
            "user_id": userID,
67
        })
68

69
        HandleAppError(c, err)
70
        return
71
    }
72

73
    // Convert to API response format
74
    response := api.Snippet{
75
        Id:              &snippet.ID,
76
        UserId:          &snippet.UserID,
77
        OriginalText:    &snippet.OriginalText,
78
        TranslatedText:  &snippet.TranslatedText,
79
        SourceLanguage:  &snippet.SourceLanguage,
80
        TargetLanguage:  &snippet.TargetLanguage,
81
        QuestionId:      snippet.QuestionID,
82
        SectionId:       snippet.SectionID,
83
        StoryId:         snippet.StoryID,
84
        Context:         snippet.Context,
85
        DifficultyLevel: snippet.DifficultyLevel,
86
        CreatedAt:       &snippet.CreatedAt,
87
        UpdatedAt:       &snippet.UpdatedAt,
88
    }
89

90
    span.SetAttributes(
91
        attribute.Int64("snippet.id", snippet.ID),
92
        attribute.Int64("user.id", int64(userID)),
93
        attribute.String("snippet.original_text", snippet.OriginalText),
94
        attribute.String("snippet.translated_text", snippet.TranslatedText),
95
        attribute.String("snippet.source_language", snippet.SourceLanguage),
96
        attribute.String("snippet.target_language", snippet.TargetLanguage),
97
    )
98
    if snippet.QuestionID != nil {
99
        span.SetAttributes(attribute.Int64("snippet.question_id", *snippet.QuestionID))
100
    }
101
    if snippet.Context != nil {
102
        span.SetAttributes(attribute.String("snippet.context", *snippet.Context))
103
    }
104
    if snippet.DifficultyLevel != nil {
105
        span.SetAttributes(attribute.String("snippet.difficulty_level", *snippet.DifficultyLevel))
106
    }
107

108
    c.JSON(http.StatusCreated, response)
109
}
110

111
// GetSnippets handles GET /v1/snippets
112
7x
func (h *SnippetsHandler) GetSnippets(c *gin.Context) {
113
7x
    ctx, span := observability.TraceSnippetFunction(c.Request.Context(), "get_snippets")
114
7x
    defer observability.FinishSpan(span, nil)
115
7x

116
7x
    // Get user ID from context (set by auth middleware)
117
7x
    userID, exists := GetUserIDFromSession(c)
118
7x
    if !exists {
119
        h.logger.Warn(ctx, "User ID not found in context")
120
        HandleAppError(c, contextutils.ErrUnauthorized)
121
        return
122
    }
123
7x
    username, exists := GetUsernameFromSession(c)
124
7x
    if !exists {
125
        h.logger.Warn(ctx, "Username not found in context")
126
        HandleAppError(c, contextutils.ErrUnauthorized)
127
        return
128
    }
129
7x
    span.SetAttributes(attribute.Int64("user.id", int64(userID)))
130
7x
    span.SetAttributes(attribute.String("user.username", username))
131
7x
    // Parse query parameters
132
7x
    params := api.GetV1SnippetsParams{}
133
7x

134
7x
    if q := c.Query("q"); q != "" {
135
1x
        params.Q = &q
136
1x
    }
137
7x
    if sourceLang := c.Query("source_lang"); sourceLang != "" {
138
        params.SourceLang = &sourceLang
139
    }
140
7x
    if targetLang := c.Query("target_lang"); targetLang != "" {
141
        params.TargetLang = &targetLang
142
    }
143
7x
    if storyIDStr := c.Query("story_id"); storyIDStr != "" {
144
2x
        if storyID, err := strconv.ParseInt(storyIDStr, 10, 64); err == nil {
145
1x
            params.StoryId = &storyID
146
1x
        }
147
    }
148
7x
    if level := c.Query("level"); level != "" {
149
4x
        params.Level = (*api.GetV1SnippetsParamsLevel)(&level)
150
4x
    }
151
7x
    if limitStr := c.Query("limit"); limitStr != "" {
152
        if limit, err := strconv.Atoi(limitStr); err == nil {
153
            params.Limit = &limit
154
        }
155
    }
156
7x
    if offsetStr := c.Query("offset"); offsetStr != "" {
157
        if offset, err := strconv.Atoi(offsetStr); err == nil {
158
            params.Offset = &offset
159
        }
160
    }
161
7x
    if params.Limit != nil {
162
        span.SetAttributes(attribute.Int("params.limit", *params.Limit))
163
    }
164
7x
    if params.Offset != nil {
165
        span.SetAttributes(attribute.Int("params.offset", *params.Offset))
166
    }
167
7x
    if q := params.Q; q != nil {
168
1x
        span.SetAttributes(attribute.String("params.q", *q))
169
1x
    }
170
7x
    if sourceLang := params.SourceLang; sourceLang != nil {
171
        span.SetAttributes(attribute.String("params.source_lang", *sourceLang))
172
    }
173
7x
    if targetLang := params.TargetLang; targetLang != nil {
174
        span.SetAttributes(attribute.String("params.target_lang", *targetLang))
175
    }
176
7x
    if storyID := params.StoryId; storyID != nil {
177
1x
        span.SetAttributes(attribute.Int64("params.story_id", *storyID))
178
1x
    }
179
7x
    if level := params.Level; level != nil {
180
4x
        span.SetAttributes(attribute.String("params.level", string(*level)))
181
4x
    }
182
7x
    snippetList, err := h.snippetsService.GetSnippets(ctx, int64(userID), params)
183
7x
    if err != nil {
184
        h.logger.Error(ctx, "Failed to get snippets", err, map[string]any{
185
            "user_id": userID,
186
        })
187
        HandleAppError(c, err)
188
        return
189
    }
190

191
7x
    c.JSON(http.StatusOK, snippetList)
192
}
193

194
// GetSnippetsByQuestion handles GET /v1/snippets/by-question/:question_id
195
func (h *SnippetsHandler) GetSnippetsByQuestion(c *gin.Context) {
196
    ctx, span := observability.TraceSnippetFunction(c.Request.Context(), "get_snippets_by_question")
197
    defer observability.FinishSpan(span, nil)
198

199
    // Get user ID from context (set by auth middleware)
200
    userID, exists := GetUserIDFromSession(c)
201
    if !exists {
202
        h.logger.Warn(ctx, "User ID not found in context")
203
        HandleAppError(c, contextutils.ErrUnauthorized)
204
        return
205
    }
206
    username, exists := GetUsernameFromSession(c)
207
    if !exists {
208
        h.logger.Warn(ctx, "Username not found in context")
209
        HandleAppError(c, contextutils.ErrUnauthorized)
210
        return
211
    }
212
    span.SetAttributes(attribute.Int64("user.id", int64(userID)))
213
    span.SetAttributes(attribute.String("user.username", username))
214

215
    // Parse question_id from path parameter
216
    questionIDStr := c.Param("question_id")
217
    questionID, err := strconv.ParseInt(questionIDStr, 10, 64)
218
    if err != nil {
219
        h.logger.Warn(ctx, "Invalid question_id parameter", map[string]any{
220
            "question_id": questionIDStr,
221
            "error":       err.Error(),
222
        })
223
        HandleAppError(c, contextutils.ErrInvalidInput)
224
        return
225
    }
226

227
    span.SetAttributes(attribute.Int64("question.id", questionID))
228

229
    // Get snippets for this question
230
    snippets, err := h.snippetsService.GetSnippetsByQuestion(ctx, int64(userID), questionID)
231
    if err != nil {
232
        h.logger.Error(ctx, "Failed to get snippets by question", err, map[string]any{
233
            "user_id":     userID,
234
            "question_id": questionID,
235
        })
236
        HandleAppError(c, contextutils.WrapError(err, "failed to get snippets by question"))
237
        return
238
    }
239

240
    // Return response with snippets array
241
    response := gin.H{
242
        "snippets": snippets,
243
    }
244

245
    c.JSON(http.StatusOK, response)
246
}
247

248
// GetSnippetsBySection handles GET /v1/snippets/by-section/:section_id
249
func (h *SnippetsHandler) GetSnippetsBySection(c *gin.Context) {
250
    ctx, span := observability.TraceSnippetFunction(c.Request.Context(), "get_snippets_by_section")
251
    defer observability.FinishSpan(span, nil)
252

253
    // Get user ID from context (set by auth middleware)
254
    userID, exists := GetUserIDFromSession(c)
255
    if !exists {
256
        h.logger.Warn(ctx, "User ID not found in context")
257
        HandleAppError(c, contextutils.ErrUnauthorized)
258
        return
259
    }
260
    username, exists := GetUsernameFromSession(c)
261
    if !exists {
262
        h.logger.Warn(ctx, "Username not found in context")
263
        HandleAppError(c, contextutils.ErrUnauthorized)
264
        return
265
    }
266
    span.SetAttributes(attribute.Int64("user.id", int64(userID)))
267
    span.SetAttributes(attribute.String("user.username", username))
268

269
    // Parse section_id from path parameter
270
    sectionIDStr := c.Param("section_id")
271
    sectionID, err := strconv.ParseInt(sectionIDStr, 10, 64)
272
    if err != nil {
273
        h.logger.Warn(ctx, "Invalid section_id parameter", map[string]any{
274
            "section_id": sectionIDStr,
275
            "error":      err.Error(),
276
        })
277
        HandleAppError(c, contextutils.ErrInvalidInput)
278
        return
279
    }
280

281
    span.SetAttributes(attribute.Int64("section.id", sectionID))
282

283
    // Get snippets for this section
284
    snippets, err := h.snippetsService.GetSnippetsBySection(ctx, int64(userID), sectionID)
285
    if err != nil {
286
        h.logger.Error(ctx, "Failed to get snippets by section", err, map[string]any{
287
            "user_id":    userID,
288
            "section_id": sectionID,
289
        })
290
        HandleAppError(c, contextutils.WrapError(err, "failed to get snippets by section"))
291
        return
292
    }
293

294
    // Return response with snippets array
295
    response := gin.H{
296
        "snippets": snippets,
297
    }
298

299
    c.JSON(http.StatusOK, response)
300
}
301

302
// GetSnippetsByStory handles GET /v1/snippets/by-story/:story_id
303
func (h *SnippetsHandler) GetSnippetsByStory(c *gin.Context) {
304
    ctx, span := observability.TraceSnippetFunction(c.Request.Context(), "get_snippets_by_story")
305
    defer observability.FinishSpan(span, nil)
306

307
    // Get user ID from context (set by auth middleware)
308
    userID, exists := GetUserIDFromSession(c)
309
    if !exists {
310
        h.logger.Warn(ctx, "User ID not found in context")
311
        HandleAppError(c, contextutils.ErrUnauthorized)
312
        return
313
    }
314
    username, exists := GetUsernameFromSession(c)
315
    if !exists {
316
        h.logger.Warn(ctx, "Username not found in context")
317
        HandleAppError(c, contextutils.ErrUnauthorized)
318
        return
319
    }
320
    span.SetAttributes(attribute.Int64("user.id", int64(userID)))
321
    span.SetAttributes(attribute.String("user.username", username))
322

323
    // Parse story_id from path parameter
324
    storyIDStr := c.Param("story_id")
325
    storyID, err := strconv.ParseInt(storyIDStr, 10, 64)
326
    if err != nil {
327
        h.logger.Warn(ctx, "Invalid story_id parameter", map[string]any{
328
            "story_id": storyIDStr,
329
            "error":    err.Error(),
330
        })
331
        HandleAppError(c, contextutils.ErrInvalidInput)
332
        return
333
    }
334

335
    span.SetAttributes(attribute.Int64("story.id", storyID))
336

337
    // Get snippets for this story
338
    snippets, err := h.snippetsService.GetSnippetsByStory(ctx, int64(userID), storyID)
339
    if err != nil {
340
        h.logger.Error(ctx, "Failed to get snippets by story", err, map[string]any{
341
            "user_id":  userID,
342
            "story_id": storyID,
343
        })
344
        HandleAppError(c, contextutils.WrapError(err, "failed to get snippets by story"))
345
        return
346
    }
347

348
    // Return response with snippets array
349
    response := gin.H{
350
        "snippets": snippets,
351
    }
352

353
    c.JSON(http.StatusOK, response)
354
}
355

356
// SearchSnippets handles GET /v1/snippets/search
357
func (h *SnippetsHandler) SearchSnippets(c *gin.Context) {
358
    ctx, span := observability.TraceSnippetFunction(c.Request.Context(), "search_snippets")
359
    defer observability.FinishSpan(span, nil)
360

361
    // Get user ID from context (set by auth middleware)
362
    userID, exists := GetUserIDFromSession(c)
363
    if !exists {
364
        h.logger.Warn(ctx, "User ID not found in context")
365
        HandleAppError(c, contextutils.ErrUnauthorized)
366
        return
367
    }
368
    username, exists := GetUsernameFromSession(c)
369
    if !exists {
370
        h.logger.Warn(ctx, "Username not found in context")
371
        HandleAppError(c, contextutils.ErrUnauthorized)
372
        return
373
    }
374
    span.SetAttributes(attribute.Int64("user.id", int64(userID)))
375
    span.SetAttributes(attribute.String("user.username", username))
376

377
    // Parse query parameters
378
    query := c.Query("q")
379
    if query == "" {
380
        HandleAppError(c, contextutils.ErrInvalidInput)
381
        return
382
    }
383

384
    limitStr := c.DefaultQuery("limit", "20")
385
    offsetStr := c.DefaultQuery("offset", "0")
386

387
    // Optional filters
388
    sourceLang := c.Query("source_lang")
389

390
    limit, err := strconv.Atoi(limitStr)
391
    if err != nil || limit < 1 {
392
        limit = 20
393
    }
394
    if limit > 100 {
395
        limit = 100
396
    }
397

398
    offset, err := strconv.Atoi(offsetStr)
399
    if err != nil || offset < 0 {
400
        offset = 0
401
    }
402

403
    span.SetAttributes(
404
        attribute.String("query", query),
405
        attribute.Int("limit", limit),
406
        attribute.Int("offset", offset),
407
    )
408
    if sourceLang != "" {
409
        span.SetAttributes(attribute.String("params.source_lang", sourceLang))
410
    }
411

412
    // Search snippets
413
    var sourceLangPtr *string
414
    if sourceLang != "" {
415
        sourceLangPtr = &sourceLang
416
    }
417
    snippets, total, err := h.snippetsService.SearchSnippets(ctx, int64(userID), query, limit, offset, sourceLangPtr)
418
    if err != nil {
419
        h.logger.Error(ctx, "Failed to search snippets", err, map[string]any{
420
            "user_id": userID,
421
            "query":   query,
422
            "limit":   limit,
423
            "offset":  offset,
424
        })
425
        HandleAppError(c, contextutils.WrapError(err, "failed to search snippets"))
426
        return
427
    }
428

429
    // Add metadata to response
430
    response := gin.H{
431
        "snippets": snippets,
432
        "query":    query,
433
        "total":    total,
434
        "limit":    limit,
435
        "offset":   offset,
436
    }
437

438
    c.JSON(http.StatusOK, response)
439
}
440

441
// GetSnippet handles GET /v1/snippets/{id}
442
func (h *SnippetsHandler) GetSnippet(c *gin.Context) {
443
    ctx, span := observability.TraceSnippetFunction(c.Request.Context(), "get_snippet")
444
    defer observability.FinishSpan(span, nil)
445

446
    // Get user ID from context (set by auth middleware)
447
    userID, exists := GetUserIDFromSession(c)
448
    if !exists {
449
        h.logger.Warn(ctx, "User ID not found in context")
450
        HandleAppError(c, contextutils.ErrUnauthorized)
451
        return
452
    }
453
    username, exists := GetUsernameFromSession(c)
454
    if !exists {
455
        h.logger.Warn(ctx, "Username not found in context")
456
        HandleAppError(c, contextutils.ErrUnauthorized)
457
        return
458
    }
459
    span.SetAttributes(attribute.String("user.username", username))
460
    span.SetAttributes(attribute.Int64("user.id", int64(userID)))
461

462
    // Parse snippet ID from URL parameter
463
    snippetIDStr := c.Param("id")
464
    snippetID, err := strconv.ParseInt(snippetIDStr, 10, 64)
465
    if err != nil {
466
        h.logger.Warn(ctx, "Invalid snippet ID format", map[string]interface{}{
467
            "snippet_id": snippetIDStr,
468
            "error":      err.Error(),
469
        })
470
        HandleAppError(c, contextutils.ErrInvalidFormat)
471
        return
472
    }
473

474
    snippet, err := h.snippetsService.GetSnippet(ctx, int64(userID), snippetID)
475
    if err != nil {
476
        h.logger.Error(ctx, "Failed to get snippet", err, map[string]interface{}{
477
            "user_id":    userID,
478
            "snippet_id": snippetID,
479
        })
480

481
        HandleAppError(c, err)
482
        return
483
    }
484

485
    // Convert to API response format
486
    response := api.Snippet{
487
        Id:              &snippet.ID,
488
        UserId:          &snippet.UserID,
489
        OriginalText:    &snippet.OriginalText,
490
        TranslatedText:  &snippet.TranslatedText,
491
        SourceLanguage:  &snippet.SourceLanguage,
492
        TargetLanguage:  &snippet.TargetLanguage,
493
        QuestionId:      snippet.QuestionID,
494
        Context:         snippet.Context,
495
        DifficultyLevel: snippet.DifficultyLevel,
496
        CreatedAt:       &snippet.CreatedAt,
497
        UpdatedAt:       &snippet.UpdatedAt,
498
    }
499

500
    span.SetAttributes(
501
        attribute.Int64("snippet.id", snippet.ID),
502
        attribute.Int64("user.id", int64(userID)),
503
        attribute.String("user.username", username),
504
        attribute.String("snippet.original_text", snippet.OriginalText),
505
        attribute.String("snippet.translated_text", snippet.TranslatedText),
506
        attribute.String("snippet.source_language", snippet.SourceLanguage),
507
        attribute.String("snippet.target_language", snippet.TargetLanguage),
508
    )
509
    if snippet.QuestionID != nil {
510
        span.SetAttributes(attribute.Int64("snippet.question_id", *snippet.QuestionID))
511
    }
512
    if snippet.Context != nil {
513
        span.SetAttributes(attribute.String("snippet.context", *snippet.Context))
514
    }
515
    if snippet.DifficultyLevel != nil {
516
        span.SetAttributes(attribute.String("snippet.difficulty_level", *snippet.DifficultyLevel))
517
    }
518

519
    c.JSON(http.StatusOK, response)
520
}
521

522
// UpdateSnippet handles PUT /v1/snippets/{id}
523
func (h *SnippetsHandler) UpdateSnippet(c *gin.Context) {
524
    ctx, span := observability.TraceSnippetFunction(c.Request.Context(), "update_snippet")
525
    defer observability.FinishSpan(span, nil)
526

527
    // Get user ID from context (set by auth middleware)
528
    userID, exists := GetUserIDFromSession(c)
529
    if !exists {
530
        h.logger.Warn(ctx, "User ID not found in context")
531
        HandleAppError(c, contextutils.ErrUnauthorized)
532
        return
533
    }
534
    username, exists := GetUsernameFromSession(c)
535
    if !exists {
536
        h.logger.Warn(ctx, "Username not found in context")
537
        HandleAppError(c, contextutils.ErrUnauthorized)
538
        return
539
    }
540
    span.SetAttributes(attribute.String("user.username", username))
541
    span.SetAttributes(attribute.Int64("user.id", int64(userID)))
542

543
    // Parse snippet ID from URL parameter
544
    snippetIDStr := c.Param("id")
545
    snippetID, err := strconv.ParseInt(snippetIDStr, 10, 64)
546
    if err != nil {
547
        h.logger.Warn(ctx, "Invalid snippet ID format", map[string]interface{}{
548
            "snippet_id": snippetIDStr,
549
            "error":      err.Error(),
550
        })
551
        HandleAppError(c, contextutils.ErrInvalidFormat)
552
        return
553
    }
554

555
    var req api.UpdateSnippetRequest
556
    if err := c.ShouldBindJSON(&req); err != nil {
557
        h.logger.Warn(ctx, "Invalid update snippet request format", map[string]interface{}{
558
            "error": err.Error(),
559
        })
560
        HandleAppError(c, contextutils.ErrInvalidInput)
561
        return
562
    }
563

564
    snippet, err := h.snippetsService.UpdateSnippet(ctx, int64(userID), snippetID, req)
565
    if err != nil {
566
        h.logger.Error(ctx, "Failed to update snippet", err, map[string]interface{}{
567
            "user_id":    userID,
568
            "snippet_id": snippetID,
569
        })
570

571
        HandleAppError(c, err)
572
        return
573
    }
574

575
    // Convert to API response format
576
    response := api.Snippet{
577
        Id:              &snippet.ID,
578
        UserId:          &snippet.UserID,
579
        OriginalText:    &snippet.OriginalText,
580
        TranslatedText:  &snippet.TranslatedText,
581
        SourceLanguage:  &snippet.SourceLanguage,
582
        TargetLanguage:  &snippet.TargetLanguage,
583
        QuestionId:      snippet.QuestionID,
584
        Context:         snippet.Context,
585
        DifficultyLevel: snippet.DifficultyLevel,
586
        CreatedAt:       &snippet.CreatedAt,
587
        UpdatedAt:       &snippet.UpdatedAt,
588
    }
589

590
    span.SetAttributes(
591
        attribute.Int64("snippet.id", snippet.ID),
592
        attribute.Int64("user.id", int64(userID)),
593
        attribute.String("user.username", username),
594
        attribute.String("snippet.original_text", snippet.OriginalText),
595
        attribute.String("snippet.translated_text", snippet.TranslatedText),
596
        attribute.String("snippet.source_language", snippet.SourceLanguage),
597
        attribute.String("snippet.target_language", snippet.TargetLanguage),
598
    )
599
    if snippet.QuestionID != nil {
600
        span.SetAttributes(attribute.Int64("snippet.question_id", *snippet.QuestionID))
601
    }
602
    if snippet.Context != nil {
603
        span.SetAttributes(attribute.String("snippet.context", *snippet.Context))
604
    }
605
    if snippet.DifficultyLevel != nil {
606
        span.SetAttributes(attribute.String("snippet.difficulty_level", *snippet.DifficultyLevel))
607
    }
608

609
    c.JSON(http.StatusOK, response)
610
}
611

612
// DeleteSnippet handles DELETE /v1/snippets/{id}
613
func (h *SnippetsHandler) DeleteSnippet(c *gin.Context) {
614
    ctx, span := observability.TraceSnippetFunction(c.Request.Context(), "delete_snippet")
615
    defer observability.FinishSpan(span, nil)
616

617
    // Get user ID from context (set by auth middleware)
618
    userID, exists := GetUserIDFromSession(c)
619
    if !exists {
620
        h.logger.Warn(ctx, "User ID not found in context")
621
        HandleAppError(c, contextutils.ErrUnauthorized)
622
        return
623
    }
624
    username, exists := GetUsernameFromSession(c)
625
    if !exists {
626
        h.logger.Warn(ctx, "Username not found in context")
627
        HandleAppError(c, contextutils.ErrUnauthorized)
628
        return
629
    }
630
    span.SetAttributes(attribute.String("user.username", username))
631
    span.SetAttributes(attribute.Int64("user.id", int64(userID)))
632

633
    // Parse snippet ID from URL parameter
634
    snippetIDStr := c.Param("id")
635
    snippetID, err := strconv.ParseInt(snippetIDStr, 10, 64)
636
    if err != nil {
637
        h.logger.Warn(ctx, "Invalid snippet ID format", map[string]interface{}{
638
            "snippet_id": snippetIDStr,
639
            "error":      err.Error(),
640
        })
641
        HandleAppError(c, contextutils.ErrInvalidFormat)
642
        return
643
    }
644

645
    err = h.snippetsService.DeleteSnippet(ctx, int64(userID), snippetID)
646
    if err != nil {
647
        h.logger.Error(ctx, "Failed to delete snippet", err, map[string]interface{}{
648
            "user_id":    userID,
649
            "snippet_id": snippetID,
650
        })
651

652
        HandleAppError(c, err)
653
        return
654
    }
655

656
    span.SetAttributes(
657
        attribute.Int64("snippet.id", snippetID),
658
        attribute.Int64("user.id", int64(userID)),
659
        attribute.String("user.username", username),
660
    )
661

662
    c.Status(http.StatusNoContent)
663
}
664

665
// DeleteAllSnippets handles DELETE /v1/snippets
666
func (h *SnippetsHandler) DeleteAllSnippets(c *gin.Context) {
667
    ctx, span := observability.TraceSnippetFunction(c.Request.Context(), "delete_all_snippets")
668
    defer observability.FinishSpan(span, nil)
669

670
    // Get user ID from context (set by auth middleware)
671
    userID, exists := GetUserIDFromSession(c)
672
    if !exists {
673
        h.logger.Warn(ctx, "User ID not found in context")
674
        HandleAppError(c, contextutils.ErrUnauthorized)
675
        return
676
    }
677
    username, exists := GetUsernameFromSession(c)
678
    if !exists {
679
        h.logger.Warn(ctx, "Username not found in context")
680
        HandleAppError(c, contextutils.ErrUnauthorized)
681
        return
682
    }
683
    span.SetAttributes(attribute.String("user.username", username))
684
    span.SetAttributes(attribute.Int64("user.id", int64(userID)))
685

686
    err := h.snippetsService.DeleteAllSnippets(ctx, int64(userID))
687
    if err != nil {
688
        h.logger.Error(ctx, "Failed to delete all snippets", err, map[string]interface{}{
689
            "user_id": userID,
690
        })
691

692
        HandleAppError(c, contextutils.ErrInternalError)
693
        return
694
    }
695

696
    c.Status(http.StatusNoContent)
697
}
698


			
quizapp internal handlers worker_admin_handler.go
47.9%
Statements
161/336
1
package handlers
2

3
import (
4
    "bytes"
5
    "context"
6
    "errors"
7
    "fmt"
8
    "net/http"
9
    "strconv"
10
    "strings"
11

12
    "quizapp/internal/api"
13
    "quizapp/internal/config"
14
    "quizapp/internal/models"
15
    "quizapp/internal/observability"
16
    "quizapp/internal/services"
17
    contextutils "quizapp/internal/utils"
18

19
    "github.com/gin-gonic/gin"
20
    "github.com/jung-kurt/gofpdf"
21
    "github.com/lib/pq"
22
    "go.opentelemetry.io/otel/attribute"
23
)
24

25
// StoryHandler handles story-related HTTP requests
26
type StoryHandler struct {
27
    storyService services.StoryServiceInterface
28
    userService  services.UserServiceInterface
29
    aiService    services.AIServiceInterface
30
    cfg          *config.Config
31
    logger       *observability.Logger
32
}
33

34
// NewStoryHandler creates a new StoryHandler
35
func NewStoryHandler(
36
    storyService services.StoryServiceInterface,
37
    userService services.UserServiceInterface,
38
    aiService services.AIServiceInterface,
39
    cfg *config.Config,
40
    logger *observability.Logger,
41
24x
) *StoryHandler {
42
24x
    return &StoryHandler{
43
24x
        storyService: storyService,
44
24x
        userService:  userService,
45
24x
        aiService:    aiService,
46
24x
        cfg:          cfg,
47
24x
        logger:       logger,
48
24x
    }
49
24x
}
50

51
// CreateStory handles POST /v1/story
52
8x
func (h *StoryHandler) CreateStory(c *gin.Context) {
53
8x
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "create_story")
54
8x
    defer observability.FinishSpan(span, nil)
55
8x

56
8x
    userID, exists := GetUserIDFromSession(c)
57
8x
    if !exists {
58
        StandardizeHTTPError(c, http.StatusUnauthorized, "Unauthorized", "User session not found or invalid")
59
        return
60
    }
61

62
    // userID is already int from GetUserIDFromSession
63

64
8x
    var req models.CreateStoryRequest
65
8x
    if err := c.ShouldBindJSON(&req); err != nil {
66
        h.logger.Error(ctx, "Failed to bind story creation request", err, nil)
67
        StandardizeHTTPError(c, http.StatusBadRequest, "Invalid request format", err.Error())
68
        return
69
    }
70

71
    // Get user's language preference
72
8x
    user, err := h.userService.GetUserByID(ctx, userID)
73
8x
    if err != nil {
74
        h.logger.Error(ctx, "Failed to get user", err, map[string]interface{}{
75
            "user_id": userID,
76
        })
77
        StandardizeHTTPError(c, http.StatusInternalServerError, "Failed to get user information", err.Error())
78
        return
79
    }
80

81
    // Get the user's preferred language (handle sql.NullString)
82
8x
    language := "en" // default
83
8x
    if user.PreferredLanguage.Valid {
84
8x
        language = user.PreferredLanguage.String
85
8x
    }
86

87
8x
    story, err := h.storyService.CreateStory(ctx, uint(userID), language, &req)
88
8x
    if err != nil {
89
        h.logger.Error(ctx, "Failed to create story", err, map[string]interface{}{
90
            "user_id": userID,
91
            "title":   req.Title,
92
        })
93

94
        // Handle specific error cases
95
        if strings.Contains(err.Error(), "maximum archived stories limit reached") {
96
            StandardizeHTTPError(c, http.StatusForbidden, "Maximum archived stories limit reached", err.Error())
97
            return
98
        }
99

100
        StandardizeHTTPError(c, http.StatusInternalServerError, "Failed to create story", err.Error())
101
        return
102
    }
103

104
8x
    span.SetAttributes(
105
8x
        attribute.String("story.title", story.Title),
106
8x
        attribute.Int("story.id", int(story.ID)),
107
8x
        attribute.String("user.language", language),
108
8x
    )
109
8x

110
8x
    // Convert to API types to ensure proper serialization
111
8x
    apiStory := convertStoryToAPI(story)
112
8x
    c.JSON(http.StatusCreated, apiStory)
113
}
114

115
// GetUserStories handles GET /v1/story
116
2x
func (h *StoryHandler) GetUserStories(c *gin.Context) {
117
2x
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "get_user_stories")
118
2x
    defer observability.FinishSpan(span, nil)
119
2x

120
2x
    userID, exists := GetUserIDFromSession(c)
121
2x
    if !exists {
122
        StandardizeHTTPError(c, http.StatusUnauthorized, "Unauthorized", "User session not found or invalid")
123
        return
124
    }
125

126
2x
    includeArchivedStr := c.Query("include_archived")
127
2x
    includeArchived := includeArchivedStr == "true"
128
2x

129
2x
    stories, err := h.storyService.GetUserStories(ctx, uint(userID), includeArchived)
130
2x
    if err != nil {
131
        h.logger.Error(ctx, "Failed to get user stories", err, map[string]interface{}{
132
            "user_id":          uint(userID),
133
            "include_archived": includeArchived,
134
        })
135
        StandardizeHTTPError(c, http.StatusInternalServerError, "Failed to get stories", err.Error())
136
        return
137
    }
138

139
2x
    c.JSON(http.StatusOK, stories)
140
}
141

142
// GetCurrentStory handles GET /v1/story/current
143
9x
func (h *StoryHandler) GetCurrentStory(c *gin.Context) {
144
9x
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "get_current_story")
145
9x
    defer observability.FinishSpan(span, nil)
146
9x

147
9x
    userID, exists := GetUserIDFromSession(c)
148
9x
    if !exists {
149
        StandardizeHTTPError(c, http.StatusUnauthorized, "Unauthorized", "User session not found or invalid")
150
        return
151
    }
152

153
9x
    story, err := h.storyService.GetCurrentStory(ctx, uint(userID))
154
9x
    if err != nil {
155
        h.logger.Error(ctx, "Failed to get current story", err, map[string]interface{}{
156
            "user_id": uint(userID),
157
        })
158
        StandardizeHTTPError(c, http.StatusInternalServerError, "Failed to get current story", err.Error())
159
        return
160
    }
161

162
9x
    if story == nil {
163
1x
        StandardizeHTTPError(c, http.StatusNotFound, "No current story found", "User has no active story")
164
1x
        return
165
1x
    }
166

167
    // If story exists but has no sections, it's generating the first section
168
8x
    if len(story.Sections) == 0 {
169
5x
        c.JSON(http.StatusAccepted, api.GeneratingResponse{
170
5x
            Status:  stringPtr("generating"),
171
5x
            Message: stringPtr("Story created successfully. The first section is being generated. Please check back shortly."),
172
5x
        })
173
5x
        return
174
5x
    }
175

176
    // If story exists and has sections, show the story content
177
    // The "generating" message should only appear when there are no sections at all
178
    // (which is handled above) or when the system is actually generating a new section
179

180
    // Record views for all sections in the story (user is accessing/reading them)
181
3x
    for _, section := range story.Sections {
182
5x
        if err := h.storyService.RecordStorySectionView(ctx, uint(userID), section.ID); err != nil {
183
            h.logger.Warn(ctx, "Failed to record story section view", map[string]interface{}{
184
                "user_id":    userID,
185
                "section_id": section.ID,
186
                "story_id":   story.ID,
187
                "error":      err.Error(),
188
            })
189
            // Don't fail the request if view recording fails
190
        }
191
    }
192

193
    // Convert to API types to ensure proper serialization
194
3x
    apiStory := convertStoryWithSectionsToAPI(story)
195
3x
    c.JSON(http.StatusOK, apiStory)
196
}
197

198
// GetStory handles GET /v1/story/:id
199
2x
func (h *StoryHandler) GetStory(c *gin.Context) {
200
2x
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "get_story")
201
2x
    defer observability.FinishSpan(span, nil)
202
2x

203
2x
    userID, exists := GetUserIDFromSession(c)
204
2x
    if !exists {
205
        StandardizeHTTPError(c, http.StatusUnauthorized, "Unauthorized", "User session not found or invalid")
206
        return
207
    }
208

209
2x
    storyIDStr := c.Param("id")
210
2x
    storyID, err := strconv.ParseUint(storyIDStr, 10, 32)
211
2x
    if err != nil {
212
        StandardizeHTTPError(c, http.StatusBadRequest, "Invalid story ID", "Story ID must be a valid number")
213
        return
214
    }
215

216
2x
    story, err := h.storyService.GetStory(ctx, uint(storyID), uint(userID))
217
2x
    if err != nil {
218
        h.logger.Error(ctx, "Failed to get story", err, map[string]interface{}{
219
            "story_id": storyID,
220
            "user_id":  uint(userID),
221
        })
222

223
        if strings.Contains(err.Error(), "not found") || strings.Contains(err.Error(), "unauthorized") {
224
            StandardizeHTTPError(c, http.StatusNotFound, "Story not found", "The requested story does not exist or you don't have access to it")
225
            return
226
        }
227

228
        StandardizeHTTPError(c, http.StatusInternalServerError, "Failed to get story", err.Error())
229
        return
230
    }
231

232
    // Record views for all sections in the story (user is accessing/reading them)
233
2x
    for _, section := range story.Sections {
234
        if err := h.storyService.RecordStorySectionView(ctx, uint(userID), section.ID); err != nil {
235
            h.logger.Warn(ctx, "Failed to record story section view", map[string]interface{}{
236
                "user_id":    userID,
237
                "section_id": section.ID,
238
                "story_id":   storyID,
239
                "error":      err.Error(),
240
            })
241
            // Don't fail the request if view recording fails
242
        }
243
    }
244

245
    // Convert to API types to ensure proper serialization
246
2x
    apiStory := convertStoryWithSectionsToAPI(story)
247
2x
    c.JSON(http.StatusOK, apiStory)
248
}
249

250
// GetSection handles GET /v1/story/section/:id
251
func (h *StoryHandler) GetSection(c *gin.Context) {
252
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "get_section")
253
    defer observability.FinishSpan(span, nil)
254

255
    userID, exists := GetUserIDFromSession(c)
256
    if !exists {
257
        StandardizeHTTPError(c, http.StatusUnauthorized, "Unauthorized", "User session not found or invalid")
258
        return
259
    }
260

261
    sectionIDStr := c.Param("id")
262
    sectionID, err := strconv.ParseUint(sectionIDStr, 10, 32)
263
    if err != nil {
264
        StandardizeHTTPError(c, http.StatusBadRequest, "Invalid section ID", "Section ID must be a valid number")
265
        return
266
    }
267

268
    section, err := h.storyService.GetSection(ctx, uint(sectionID), uint(userID))
269
    if err != nil {
270
        h.logger.Error(ctx, "Failed to get section", err, map[string]interface{}{
271
            "section_id": sectionID,
272
            "user_id":    uint(userID),
273
        })
274

275
        if strings.Contains(err.Error(), "not found") || strings.Contains(err.Error(), "unauthorized") {
276
            StandardizeHTTPError(c, http.StatusNotFound, "Section not found", "The requested section does not exist or you don't have access to it")
277
            return
278
        }
279

280
        StandardizeHTTPError(c, http.StatusInternalServerError, "Failed to get section", err.Error())
281
        return
282
    }
283

284
    // Record view for this specific section (user is accessing/reading it)
285
    if err := h.storyService.RecordStorySectionView(ctx, uint(userID), uint(sectionID)); err != nil {
286
        h.logger.Warn(ctx, "Failed to record story section view", map[string]interface{}{
287
            "user_id":    userID,
288
            "section_id": sectionID,
289
            "error":      err.Error(),
290
        })
291
        // Don't fail the request if view recording fails
292
    }
293

294
    // Convert to API types to ensure proper serialization
295
    apiSection := convertStorySectionWithQuestionsToAPI(section)
296
    c.JSON(http.StatusOK, apiSection)
297
}
298

299
// GenerateNextSection handles POST /v1/story/:id/generate
300
func (h *StoryHandler) GenerateNextSection(c *gin.Context) {
301
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "generate_next_section")
302
    defer observability.FinishSpan(span, nil)
303

304
    // Create a timeout context for story generation to prevent hanging requests
305
    // Use the configured AI request timeout for consistency with other AI operations
306
    timeoutCtx, cancel := context.WithTimeout(ctx, config.AIRequestTimeout)
307
    defer cancel()
308

309
    userID, exists := GetUserIDFromSession(c)
310
    if !exists {
311
        StandardizeHTTPError(c, http.StatusUnauthorized, "Unauthorized", "User session not found or invalid")
312
        return
313
    }
314

315
    storyIDStr := c.Param("id")
316
    storyID, err := strconv.ParseUint(storyIDStr, 10, 32)
317
    if err != nil {
318
        StandardizeHTTPError(c, http.StatusBadRequest, "Invalid story ID", "Story ID must be a valid number")
319
        return
320
    }
321

322
    // Get user for AI config
323
    user, err := h.userService.GetUserByID(timeoutCtx, userID)
324
    if err != nil {
325
        h.logger.Error(ctx, "Failed to get user for generation", err, map[string]interface{}{
326
            "user_id": uint(userID),
327
        })
328
        StandardizeHTTPError(c, http.StatusInternalServerError, "Failed to get user information", err.Error())
329
        return
330
    }
331

332
    // Get user's AI configuration
333
    userAIConfig, apiKeyID := h.convertToServicesAIConfig(timeoutCtx, user)
334

335
    // Add user ID and API key ID to context for usage tracking
336
    timeoutCtx = contextutils.WithUserID(timeoutCtx, userID)
337
    if apiKeyID != nil {
338
        timeoutCtx = contextutils.WithAPIKeyID(timeoutCtx, *apiKeyID)
339
    }
340

341
    // Generate the story section using the shared service method (user generation)
342
    sectionWithQuestions, err := h.storyService.GenerateStorySection(timeoutCtx, uint(storyID), uint(userID), h.aiService, userAIConfig, models.GeneratorTypeUser)
343
    if err != nil {
344
        // Check if this is a generation limit reached error (normal business case)
345
        if errors.Is(err, contextutils.ErrGenerationLimitReached) {
346
            h.logger.Info(ctx, "User reached daily generation limit", map[string]interface{}{
347
                "story_id": storyID,
348
                "user_id":  uint(userID),
349
            })
350
            // Return 200 OK with business logic error instead of 409 Conflict
351
            c.JSON(http.StatusOK, api.ErrorResponse{
352
                Error:   stringPtr("You have already generated a section today for this story. Please try again tomorrow."),
353
                Details: stringPtr("daily generation limit reached"),
354
            })
355
            return
356
        }
357

358
        h.logger.Error(ctx, "Failed to generate story section", err, map[string]interface{}{
359
            "story_id": storyID,
360
            "user_id":  uint(userID),
361
        })
362

363
        // Check if this is a constraint violation (duplicate generation today)
364
        if pqErr, ok := err.(*pq.Error); ok && pqErr.Code == "23505" {
365
            StandardizeHTTPError(c, http.StatusConflict, "Cannot generate section", "You have already generated a section today for this story. Please try again tomorrow.")
366
            return
367
        }
368

369
        StandardizeHTTPError(c, http.StatusInternalServerError, "Failed to generate story section", err.Error())
370
        return
371
    }
372

373
    // Return success response with the generated section
374
    apiSection := convertStorySectionWithQuestionsToAPI(sectionWithQuestions)
375
    c.JSON(http.StatusCreated, apiSection)
376
}
377

378
// ArchiveStory handles POST /v1/story/:id/archive
379
6x
func (h *StoryHandler) ArchiveStory(c *gin.Context) {
380
6x
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "archive_story")
381
6x
    defer observability.FinishSpan(span, nil)
382
6x

383
6x
    userID, exists := GetUserIDFromSession(c)
384
6x
    if !exists {
385
        StandardizeHTTPError(c, http.StatusUnauthorized, "Unauthorized", "User session not found or invalid")
386
        return
387
    }
388

389
6x
    storyIDStr := c.Param("id")
390
6x
    storyID, err := strconv.ParseUint(storyIDStr, 10, 32)
391
6x
    if err != nil {
392
        StandardizeHTTPError(c, http.StatusBadRequest, "Invalid story ID", "Story ID must be a valid number")
393
        return
394
    }
395

396
6x
    err = h.storyService.ArchiveStory(ctx, uint(storyID), uint(userID))
397
6x
    if err != nil {
398
1x
        h.logger.Error(ctx, "Failed to archive story", err, map[string]interface{}{
399
1x
            "story_id": storyID,
400
1x
            "user_id":  uint(userID),
401
1x
        })
402
1x

403
1x
        if strings.Contains(err.Error(), "not found") || strings.Contains(err.Error(), "unauthorized") {
404
            StandardizeHTTPError(c, http.StatusNotFound, "Story not found", "The requested story does not exist or you don't have access to it")
405
            return
406
        }
407

408
1x
        StandardizeHTTPError(c, http.StatusInternalServerError, "Failed to archive story", err.Error())
409
1x
        return
410
    }
411

412
5x
    c.JSON(http.StatusOK, gin.H{"message": "story archived successfully"})
413
}
414

415
// CompleteStory handles POST /v1/story/:id/complete
416
1x
func (h *StoryHandler) CompleteStory(c *gin.Context) {
417
1x
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "complete_story")
418
1x
    defer observability.FinishSpan(span, nil)
419
1x

420
1x
    userID, exists := GetUserIDFromSession(c)
421
1x
    if !exists {
422
        StandardizeHTTPError(c, http.StatusUnauthorized, "Unauthorized", "User session not found or invalid")
423
        return
424
    }
425

426
1x
    storyIDStr := c.Param("id")
427
1x
    storyID, err := strconv.ParseUint(storyIDStr, 10, 32)
428
1x
    if err != nil {
429
        StandardizeHTTPError(c, http.StatusBadRequest, "Invalid story ID", "Story ID must be a valid number")
430
        return
431
    }
432

433
1x
    err = h.storyService.CompleteStory(ctx, uint(storyID), uint(userID))
434
1x
    if err != nil {
435
        h.logger.Error(ctx, "Failed to complete story", err, map[string]interface{}{
436
            "story_id": storyID,
437
            "user_id":  uint(userID),
438
        })
439

440
        if strings.Contains(err.Error(), "not found") || strings.Contains(err.Error(), "unauthorized") {
441
            StandardizeHTTPError(c, http.StatusNotFound, "Story not found", "The requested story does not exist or you don't have access to it")
442
            return
443
        }
444

445
        StandardizeHTTPError(c, http.StatusInternalServerError, "Failed to complete story", err.Error())
446
        return
447
    }
448

449
1x
    c.JSON(http.StatusOK, gin.H{"message": "story completed successfully"})
450
}
451

452
// SetCurrentStory handles POST /v1/story/:id/set-current
453
1x
func (h *StoryHandler) SetCurrentStory(c *gin.Context) {
454
1x
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "set_current_story")
455
1x
    defer observability.FinishSpan(span, nil)
456
1x

457
1x
    userID, exists := GetUserIDFromSession(c)
458
1x
    if !exists {
459
        StandardizeHTTPError(c, http.StatusUnauthorized, "Unauthorized", "User session not found or invalid")
460
        return
461
    }
462

463
1x
    storyIDStr := c.Param("id")
464
1x
    storyID, err := strconv.ParseUint(storyIDStr, 10, 32)
465
1x
    if err != nil {
466
        StandardizeHTTPError(c, http.StatusBadRequest, "Invalid story ID", "Story ID must be a valid number")
467
        return
468
    }
469

470
1x
    err = h.storyService.SetCurrentStory(ctx, uint(storyID), uint(userID))
471
1x
    if err != nil {
472
        h.logger.Error(ctx, "Failed to set current story", err, map[string]interface{}{
473
            "story_id": storyID,
474
            "user_id":  uint(userID),
475
        })
476

477
        if strings.Contains(err.Error(), "not found") || strings.Contains(err.Error(), "unauthorized") {
478
            StandardizeHTTPError(c, http.StatusNotFound, "Story not found", "The requested story does not exist or you don't have access to it")
479
            return
480
        }
481

482
        StandardizeHTTPError(c, http.StatusInternalServerError, "Failed to set current story", err.Error())
483
        return
484
    }
485

486
1x
    c.JSON(http.StatusOK, gin.H{"message": "story set as current successfully"})
487
}
488

489
// DeleteStory handles DELETE /v1/story/:id
490
func (h *StoryHandler) DeleteStory(c *gin.Context) {
491
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "delete_story")
492
    defer observability.FinishSpan(span, nil)
493

494
    userID, exists := GetUserIDFromSession(c)
495
    if !exists {
496
        StandardizeHTTPError(c, http.StatusUnauthorized, "Unauthorized", "User session not found or invalid")
497
        return
498
    }
499

500
    storyIDStr := c.Param("id")
501
    storyID, err := strconv.ParseUint(storyIDStr, 10, 32)
502
    if err != nil {
503
        StandardizeHTTPError(c, http.StatusBadRequest, "Invalid story ID", "Story ID must be a valid number")
504
        return
505
    }
506

507
    err = h.storyService.DeleteStory(ctx, uint(storyID), uint(userID))
508
    if err != nil {
509
        h.logger.Error(ctx, "Failed to delete story", err, map[string]interface{}{
510
            "story_id": storyID,
511
            "user_id":  uint(userID),
512
        })
513

514
        if strings.Contains(err.Error(), "not found") || strings.Contains(err.Error(), "unauthorized") {
515
            StandardizeHTTPError(c, http.StatusNotFound, "Story not found", "The requested story does not exist or you don't have access to it")
516
            return
517
        }
518

519
        if strings.Contains(err.Error(), "cannot delete active story") {
520
            StandardizeHTTPError(c, http.StatusConflict, "Cannot delete active story", "You cannot delete a story that is currently active")
521
            return
522
        }
523

524
        StandardizeHTTPError(c, http.StatusInternalServerError, "Failed to delete story", err.Error())
525
        return
526
    }
527

528
    c.JSON(http.StatusNoContent, nil)
529
}
530

531
// ToggleAutoGeneration handles POST /v1/story/:id/toggle-auto-generation
532
8x
func (h *StoryHandler) ToggleAutoGeneration(c *gin.Context) {
533
8x
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "toggle_auto_generation")
534
8x
    defer observability.FinishSpan(span, nil)
535
8x

536
8x
    userID, exists := GetUserIDFromSession(c)
537
8x
    if !exists {
538
        StandardizeHTTPError(c, http.StatusUnauthorized, "Unauthorized", "User session not found or invalid")
539
        return
540
    }
541

542
8x
    storyIDStr := c.Param("id")
543
8x
    storyID, err := strconv.ParseUint(storyIDStr, 10, 32)
544
8x
    if err != nil {
545
1x
        StandardizeHTTPError(c, http.StatusBadRequest, "Invalid story ID", "Story ID must be a valid number")
546
1x
        return
547
1x
    }
548

549
    // Parse request body to get the pause state
550
7x
    var req struct {
551
7x
        Paused *bool `json:"paused" binding:"required"`
552
7x
    }
553
7x
    if err := c.ShouldBindJSON(&req); err != nil {
554
2x
        h.logger.Error(ctx, "Failed to bind toggle auto-generation request", err, nil)
555
2x
        StandardizeHTTPError(c, http.StatusBadRequest, "Invalid request format", err.Error())
556
2x
        return
557
2x
    }
558

559
5x
    if req.Paused == nil {
560
        h.logger.Error(ctx, "Missing paused field in toggle auto-generation request", nil, nil)
561
        StandardizeHTTPError(c, http.StatusBadRequest, "Invalid request format", "paused field is required")
562
        return
563
    }
564

565
5x
    err = h.storyService.ToggleAutoGeneration(ctx, uint(storyID), uint(userID), *req.Paused)
566
5x
    if err != nil {
567
2x
        h.logger.Error(ctx, "Failed to toggle auto-generation", err, map[string]interface{}{
568
2x
            "story_id": storyID,
569
2x
            "user_id":  uint(userID),
570
2x
            "paused":   *req.Paused,
571
2x
        })
572
2x

573
2x
        if strings.Contains(err.Error(), "not found") || strings.Contains(err.Error(), "unauthorized") {
574
2x
            StandardizeHTTPError(c, http.StatusNotFound, "Story not found", "The requested story does not exist or you don't have access to it")
575
2x
            return
576
2x
        }
577

578
        StandardizeHTTPError(c, http.StatusInternalServerError, "Failed to toggle auto-generation", err.Error())
579
        return
580
    }
581

582
3x
    message := "Auto-generation resumed"
583
3x
    if *req.Paused {
584
2x
        message = "Auto-generation paused"
585
2x
    }
586

587
3x
    c.JSON(http.StatusOK, gin.H{"message": message, "auto_generation_paused": *req.Paused})
588
}
589

590
// ExportStory handles GET /v1/story/:id/export
591
3x
func (h *StoryHandler) ExportStory(c *gin.Context) {
592
3x
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "export_story")
593
3x
    defer observability.FinishSpan(span, nil)
594
3x

595
3x
    userID, exists := GetUserIDFromSession(c)
596
3x
    if !exists {
597
        StandardizeHTTPError(c, http.StatusUnauthorized, "Unauthorized", "User session not found or invalid")
598
        return
599
    }
600

601
3x
    storyIDStr := c.Param("id")
602
3x
    storyID, err := strconv.ParseUint(storyIDStr, 10, 32)
603
3x
    if err != nil {
604
        StandardizeHTTPError(c, http.StatusBadRequest, "Invalid story ID", "Story ID must be a valid number")
605
        return
606
    }
607

608
    // Get the story with all sections
609
3x
    story, err := h.storyService.GetStory(ctx, uint(storyID), uint(userID))
610
3x
    if err != nil {
611
1x
        h.logger.Error(ctx, "Failed to get story for export", err, map[string]interface{}{
612
1x
            "story_id": storyID,
613
1x
            "user_id":  uint(userID),
614
1x
        })
615
1x

616
1x
        if strings.Contains(err.Error(), "not found") || strings.Contains(err.Error(), "unauthorized") {
617
1x
            StandardizeHTTPError(c, http.StatusNotFound, "Story not found", "The requested story does not exist or you don't have access to it")
618
1x
            return
619
1x
        }
620

621
        StandardizeHTTPError(c, http.StatusInternalServerError, "Failed to get story", err.Error())
622
        return
623
    }
624

625
    // Create PDF
626
2x
    pdf := gofpdf.New("P", "mm", "A4", "")
627
2x

628
2x
    // Use Arial (core font) for PDF generation
629
2x
    // Note: For proper Unicode support with non-Latin characters, we would need to:
630
2x
    // 1. Add a TTF font file (e.g., DejaVu Sans) to frontend/public/fonts/
631
2x
    // 2. Generate a .json font definition file using gofpdf's makefont utility
632
2x
    // 3. Register the font using pdf.AddUTF8Font()
633
2x
    // For now, Arial provides basic support and the buffer change prevents encoding issues
634
2x

635
2x
    pdf.AddPage()
636
2x
    // Use Arial consistently; size will be overridden for headings where needed
637
2x
    pdf.SetFont("Arial", "B", 16)
638
2x

639
2x
    // Add title
640
2x
    pdf.Cell(40, 10, story.Title)
641
2x
    pdf.Ln(12)
642
2x

643
2x
    // Add story metadata if present
644
2x
    pdf.SetFont("Arial", "", 10)
645
2x
    if story.Subject != nil && *story.Subject != "" {
646
2x
        pdf.Cell(40, 8, fmt.Sprintf("Subject: %s", *story.Subject))
647
2x
        pdf.Ln(6)
648
2x
    }
649
2x
    if story.AuthorStyle != nil && *story.AuthorStyle != "" {
650
2x
        pdf.Cell(40, 8, fmt.Sprintf("Style: %s", *story.AuthorStyle))
651
2x
        pdf.Ln(6)
652
2x
    }
653
2x
    if story.Genre != nil && *story.Genre != "" {
654
1x
        pdf.Cell(40, 8, fmt.Sprintf("Genre: %s", *story.Genre))
655
1x
        pdf.Ln(6)
656
1x
    }
657
2x
    pdf.Ln(5)
658
2x

659
2x
    // Add sections
660
2x
    pdf.SetFont("Arial", "", 11)
661
2x
    for _, section := range story.Sections {
662
2x
        // Section header
663
2x
        pdf.SetFont("Arial", "B", 12)
664
2x
        pdf.Cell(40, 8, fmt.Sprintf("Section %d", section.SectionNumber))
665
2x
        pdf.Ln(8)
666
2x

667
2x
        // Section content
668
2x
        pdf.SetFont("Arial", "", 11)
669
2x

670
2x
        // Split content into paragraphs (double line breaks)
671
2x
        paragraphs := strings.Split(section.Content, "\n\n")
672
2x
        for _, paragraph := range paragraphs {
673
2x
            if paragraph != "" {
674
2x
                // MultiCell for text wrapping
675
2x
                pdf.MultiCell(0, 6, paragraph, "", "L", false)
676
2x
                pdf.Ln(3)
677
2x
            }
678
        }
679
2x
        pdf.Ln(5)
680
    }
681

682
    // Set headers for PDF download
683
2x
    filename := fmt.Sprintf("story_%s.pdf", strings.ReplaceAll(strings.ToLower(story.Title), " ", "_"))
684
2x
    c.Header("Content-Type", "application/pdf")
685
2x
    c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s", filename))
686
2x

687
2x
    var buf bytes.Buffer
688
2x
    err = pdf.Output(&buf)
689
2x
    if err != nil {
690
        h.logger.Error(ctx, "Failed to generate PDF", err, map[string]interface{}{
691
            "story_id": storyID,
692
        })
693
        StandardizeHTTPError(c, http.StatusInternalServerError, "Failed to generate PDF", err.Error())
694
        return
695
    }
696

697
2x
    c.Data(http.StatusOK, "application/pdf", buf.Bytes())
698
}
699

700
// convertToServicesAIConfig creates AI config for the user in services format
701
func (h *StoryHandler) convertToServicesAIConfig(ctx context.Context, user *models.User) (*models.UserAIConfig, *int) {
702
    // Handle sql.NullString fields
703
    aiProvider := ""
704
    if user.AIProvider.Valid {
705
        aiProvider = user.AIProvider.String
706
    }
707

708
    aiModel := ""
709
    if user.AIModel.Valid {
710
        aiModel = user.AIModel.String
711
    }
712

713
    apiKey := ""
714
    var apiKeyID *int
715
    if aiProvider != "" {
716
        savedKey, keyID, err := h.userService.GetUserAPIKeyWithID(ctx, user.ID, aiProvider)
717
        if err == nil && savedKey != "" {
718
            apiKey = savedKey
719
            apiKeyID = keyID
720
        }
721
    }
722

723
    return &models.UserAIConfig{
724
        Provider: aiProvider,
725
        Model:    aiModel,
726
        APIKey:   apiKey,
727
        Username: user.Username,
728
    }, apiKeyID
729
}
730


			
quizapp internal handlers worker_admin_handler.go
27.3%
Statements
15/55
1
//go:build integration
2

3
package handlers
4

5
import (
6
    "context"
7
    "encoding/json"
8
    "strings"
9

10
    "quizapp/internal/config"
11
    "quizapp/internal/models"
12
    "quizapp/internal/observability"
13
    "quizapp/internal/services"
14
    contextutils "quizapp/internal/utils"
15
)
16

17
// MockAIService implements AIServiceInterface for testing
18
type MockAIService struct {
19
    realService *services.AIService
20
}
21

22
3x
func NewMockAIService(cfg *config.Config, logger *observability.Logger) *MockAIService {
23
3x
    return &MockAIService{
24
3x
        realService: services.NewAIService(cfg, logger, services.NewNoopUsageStatsService()),
25
3x
    }
26
3x
}
27

28
// TestConnection returns a mock response for AI connection tests
29
1x
func (m *MockAIService) TestConnection(ctx context.Context, provider, model, apiKey string) error {
30
1x
    // For testing purposes, return success for valid-looking inputs
31
1x
    if provider != "" && model != "" {
32
1x
        // If it's a test API key, return an error to simulate failure
33
1x
        if strings.Contains(apiKey, "test") || apiKey == "" {
34
1x
            return contextutils.ErrorWithContextf("invalid API key")
35
1x
        }
36
        return nil
37
    }
38
    return contextutils.ErrorWithContextf("missing provider or model")
39
}
40

41
// CallWithPrompt returns a mock response for AI fix requests, otherwise delegates to real service
42
2x
func (m *MockAIService) CallWithPrompt(ctx context.Context, userConfig *models.UserAIConfig, prompt, grammar string) (string, error) {
43
2x
    // Check if this is an AI fix request by looking for fix-related keywords in the prompt
44
2x
    if strings.Contains(prompt, "fix") || strings.Contains(prompt, "Fix") ||
45
2x
        strings.Contains(prompt, "problematic") || strings.Contains(prompt, "report") {
46
2x
        // Return a mock AI fix response
47
2x
        mockResponse := map[string]interface{}{
48
2x
            "content": map[string]interface{}{
49
2x
                "question":       "What is the capital of France?",
50
2x
                "options":        []string{"Paris", "London", "Berlin", "Madrid"},
51
2x
                "correct_answer": 0,
52
2x
                "explanation":    "Paris is the capital and largest city of France.",
53
2x
            },
54
2x
            "correct_answer": 0,
55
2x
            "explanation":    "Paris is the capital and largest city of France.",
56
2x
            "change_reason":  "Fixed grammar and improved clarity of the question.",
57
2x
        }
58
2x

59
2x
        responseJSON, err := json.Marshal(mockResponse)
60
2x
        if err != nil {
61
            return "", err
62
        }
63
2x
        return string(responseJSON), nil
64
    }
65

66
    // For non-fix requests, delegate to the real service
67
    if m.realService != nil {
68
        return m.realService.CallWithPrompt(ctx, userConfig, prompt, grammar)
69
    }
70

71
    // Fallback response
72
    return `{"response": "Mock AI response"}`, nil
73
}
74

75
// Implement other required methods by delegating to real service or returning defaults
76
func (m *MockAIService) GenerateQuestion(ctx context.Context, userConfig *models.UserAIConfig, req *models.AIQuestionGenRequest) (*models.Question, error) {
77
    if m.realService != nil {
78
        return m.realService.GenerateQuestion(ctx, userConfig, req)
79
    }
80
    return nil, contextutils.ErrorWithContextf("GenerateQuestion not implemented in mock")
81
}
82

83
func (m *MockAIService) GenerateQuestions(ctx context.Context, userConfig *models.UserAIConfig, req *models.AIQuestionGenRequest) ([]*models.Question, error) {
84
    if m.realService != nil {
85
        return m.realService.GenerateQuestions(ctx, userConfig, req)
86
    }
87
    return nil, contextutils.ErrorWithContextf("GenerateQuestions not implemented in mock")
88
}
89

90
func (m *MockAIService) GenerateQuestionsStream(ctx context.Context, userConfig *models.UserAIConfig, req *models.AIQuestionGenRequest, progress chan<- *models.Question, variety *services.VarietyElements) error {
91
    if m.realService != nil {
92
        return m.realService.GenerateQuestionsStream(ctx, userConfig, req, progress, variety)
93
    }
94
    return contextutils.ErrorWithContextf("GenerateQuestionsStream not implemented in mock")
95
}
96

97
func (m *MockAIService) GenerateChatResponse(ctx context.Context, userConfig *models.UserAIConfig, req *models.AIChatRequest) (string, error) {
98
    if m.realService != nil {
99
        return m.realService.GenerateChatResponse(ctx, userConfig, req)
100
    }
101
    return "Mock chat response", nil
102
}
103

104
func (m *MockAIService) GenerateChatResponseStream(ctx context.Context, userConfig *models.UserAIConfig, req *models.AIChatRequest, chunks chan<- string) error {
105
    if m.realService != nil {
106
        return m.realService.GenerateChatResponseStream(ctx, userConfig, req, chunks)
107
    }
108
    select {
109
    case chunks <- "Mock streaming response":
110
    default:
111
    }
112
    return nil
113
}
114

115
1x
func (m *MockAIService) GetConcurrencyStats() services.ConcurrencyStats {
116
1x
    if m.realService != nil {
117
1x
        return m.realService.GetConcurrencyStats()
118
1x
    }
119
    return services.ConcurrencyStats{}
120
}
121

122
func (m *MockAIService) GetQuestionBatchSize(provider string) int {
123
    if m.realService != nil {
124
        return m.realService.GetQuestionBatchSize(provider)
125
    }
126
    return 1
127
}
128

129
func (m *MockAIService) VarietyService() *services.VarietyService {
130
    if m.realService != nil {
131
        return m.realService.VarietyService()
132
    }
133
    return nil
134
}
135

136
4x
func (m *MockAIService) TemplateManager() *services.AITemplateManager {
137
4x
    if m.realService != nil {
138
4x
        return m.realService.TemplateManager()
139
4x
    }
140
    return nil
141
}
142

143
func (m *MockAIService) GenerateStoryQuestions(ctx context.Context, userConfig *models.UserAIConfig, req *models.StoryQuestionsRequest) ([]*models.StorySectionQuestionData, error) {
144
    if m.realService != nil {
145
        return m.realService.GenerateStoryQuestions(ctx, userConfig, req)
146
    }
147
    // Return mock data for testing
148
    return []*models.StorySectionQuestionData{
149
        {
150
            QuestionText:       "What is the main character doing?",
151
            Options:            []string{"Reading", "Writing", "Running", "Swimming"},
152
            CorrectAnswerIndex: 0,
153
            Explanation:        stringPtr("The main character is reading a book"),
154
        },
155
    }, nil
156
}
157

158
func (m *MockAIService) GenerateStorySection(ctx context.Context, userConfig *models.UserAIConfig, req *models.StoryGenerationRequest) (string, error) {
159
    if m.realService != nil {
160
        return m.realService.GenerateStorySection(ctx, userConfig, req)
161
    }
162
    // Return mock data for testing
163
    return "Once upon a time, there was a brave knight who lived in a castle...", nil
164
}
165

166
2x
func (m *MockAIService) SupportsGrammarField(provider string) bool {
167
2x
    if m.realService != nil {
168
2x
        return m.realService.SupportsGrammarField(provider)
169
2x
    }
170
    return false
171
}
172

173
func (m *MockAIService) Shutdown(ctx context.Context) error {
174
    if m.realService != nil {
175
        return m.realService.Shutdown(ctx)
176
    }
177
    return nil
178
}
179


			
quizapp internal handlers worker_admin_handler.go
2.4%
Statements
1/42
1
package handlers
2

3
import (
4
    "context"
5
    "net/http"
6

7
    "quizapp/internal/api"
8
    "quizapp/internal/config"
9
    "quizapp/internal/middleware"
10
    "quizapp/internal/observability"
11
    "quizapp/internal/serviceinterfaces"
12
    "quizapp/internal/services"
13
    contextutils "quizapp/internal/utils"
14

15
    "github.com/gin-gonic/gin"
16
    "go.opentelemetry.io/otel/attribute"
17
)
18

19
// stringPtrOrEmpty returns the string value if not nil, otherwise returns empty string
20
func stringPtrOrEmpty(s *string) string {
21
    if s == nil {
22
        return ""
23
    }
24
    return *s
25
}
26

27
// TranslationHandler handles translation related HTTP requests
28
type TranslationHandler struct {
29
    translationService services.TranslationServiceInterface
30
    cfg                *config.Config
31
    logger             *observability.Logger
32
}
33

34
// NewTranslationHandler creates a new TranslationHandler instance
35
13x
func NewTranslationHandler(translationService services.TranslationServiceInterface, cfg *config.Config, logger *observability.Logger) *TranslationHandler {
36
13x
    return &TranslationHandler{
37
13x
        translationService: translationService,
38
13x
        cfg:                cfg,
39
13x
        logger:             logger,
40
13x
    }
41
13x
}
42

43
// TranslateText handles text translation requests
44
func (h *TranslationHandler) TranslateText(c *gin.Context) {
45
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "translate_text")
46
    defer observability.FinishSpan(span, nil)
47

48
    var req api.TranslateRequest
49
    if err := c.ShouldBindJSON(&req); err != nil {
50
        h.logger.Warn(ctx, "Invalid translation request format", map[string]interface{}{"error": err.Error()})
51
        c.JSON(http.StatusBadRequest, api.ErrorResponse{
52
            Code:    stringPtr("INVALID_REQUEST"),
53
            Message: stringPtr("Invalid request format"),
54
            Error:   stringPtr(err.Error()),
55
        })
56
        return
57
    }
58

59
    // Validate input
60
    if err := h.validateTranslationRequest(ctx, req); err != nil {
61
        h.logger.Warn(ctx, "Translation request validation failed", map[string]interface{}{"error": err.Error()})
62
        c.JSON(http.StatusBadRequest, api.ErrorResponse{
63
            Code:    stringPtr("VALIDATION_ERROR"),
64
            Message: stringPtr("Request validation failed"),
65
            Error:   stringPtr(err.Error()),
66
        })
67
        return
68
    }
69

70
    // Set span attributes for observability
71
    span.SetAttributes(
72
        attribute.String("translation.target_language", req.TargetLanguage),
73
        attribute.String("translation.source_language", stringPtrOrEmpty(req.SourceLanguage)),
74
        attribute.Int("translation.text_length", len(req.Text)),
75
    )
76

77
    // Perform translation
78
    response, err := h.translationService.Translate(ctx, serviceinterfaces.TranslateRequest{
79
        Text:           req.Text,
80
        TargetLanguage: req.TargetLanguage,
81
        SourceLanguage: stringPtrOrEmpty(req.SourceLanguage),
82
    })
83
    if err != nil {
84
        h.logger.Error(ctx, "Translation failed", err)
85

86
        // Check if it's a service unavailable error
87
        if contextutils.GetErrorCode(err) == contextutils.ErrorCodeServiceUnavailable {
88
            c.JSON(http.StatusServiceUnavailable, api.ErrorResponse{
89
                Code:    stringPtr("TRANSLATION_SERVICE_UNAVAILABLE"),
90
                Message: stringPtr("Translation service is currently unavailable"),
91
                Error:   stringPtr(err.Error()),
92
            })
93
            return
94
        }
95

96
        // Default to bad request for other errors
97
        c.JSON(http.StatusBadRequest, api.ErrorResponse{
98
            Code:    stringPtr("TRANSLATION_FAILED"),
99
            Message: stringPtr("Translation failed"),
100
            Error:   stringPtr(err.Error()),
101
        })
102
        return
103
    }
104

105
    // Return successful response
106
    var confidencePtr *float32
107
    if response.Confidence > 0 {
108
        conf := float32(response.Confidence)
109
        confidencePtr = &conf
110
    }
111
    c.JSON(http.StatusOK, api.TranslateResponse{
112
        TranslatedText: response.TranslatedText,
113
        SourceLanguage: response.SourceLanguage,
114
        TargetLanguage: response.TargetLanguage,
115
        Confidence:     confidencePtr,
116
    })
117
}
118

119
// validateTranslationRequest validates the translation request
120
func (h *TranslationHandler) validateTranslationRequest(_ context.Context, req api.TranslateRequest) error {
121
    // Validate text length
122
    if len(req.Text) == 0 {
123
        return contextutils.NewAppError(contextutils.ErrorCodeInvalidInput, contextutils.SeverityError, "Text cannot be empty", "")
124
    }
125

126
    if len(req.Text) > 5000 {
127
        return contextutils.NewAppError(contextutils.ErrorCodeInvalidInput, contextutils.SeverityError, "Text cannot exceed 5000 characters", "")
128
    }
129

130
    // Validate target language
131
    if err := h.translationService.ValidateLanguageCode(req.TargetLanguage); err != nil {
132
        return contextutils.WrapError(err, "Invalid target language")
133
    }
134

135
    // Validate source language if provided
136
    if req.SourceLanguage != nil && *req.SourceLanguage != "" {
137
        if err := h.translationService.ValidateLanguageCode(*req.SourceLanguage); err != nil {
138
            return contextutils.WrapError(err, "Invalid source language")
139
        }
140
    }
141

142
    return nil
143
}
144

145
// RegisterRoutes registers the translation routes with the router
146
func (h *TranslationHandler) RegisterRoutes(router *gin.Engine) {
147
    v1 := router.Group("/v1")
148
    {
149
        v1.POST("/translate", middleware.RequireAuth(), h.TranslateText)
150
    }
151
}
152


			
quizapp internal handlers worker_admin_handler.go
44.6%
Statements
181/406
1
package handlers
2

3
import (
4
    "context"
5
    "database/sql"
6
    "errors"
7
    "fmt"
8
    "html/template"
9
    "net/http"
10
    "strconv"
11
    "strings"
12
    "time"
13

14
    "quizapp/internal/api"
15
    "quizapp/internal/config"
16
    "quizapp/internal/models"
17
    "quizapp/internal/observability"
18
    "quizapp/internal/services"
19
    contextutils "quizapp/internal/utils"
20

21
    "github.com/gin-gonic/gin"
22
)
23

24
// UserAdminHandler handles user management operations
25
type UserAdminHandler struct {
26
    userService services.UserServiceInterface
27
    cfg         *config.Config
28
    templates   *template.Template
29
    logger      *observability.Logger
30
}
31

32
// NewUserAdminHandler creates a new UserAdminHandler instance
33
13x
func NewUserAdminHandler(userService services.UserServiceInterface, cfg *config.Config, logger *observability.Logger) *UserAdminHandler {
34
13x
    return &UserAdminHandler{
35
13x
        userService: userService,
36
13x
        cfg:         cfg,
37
13x
        templates:   nil,
38
13x
        logger:      logger,
39
13x
    }
40
13x
}
41

42
// UserCreateRequest represents a request to create a new user
43
// Using the generated type from api package for automatic validation
44
type UserCreateRequest = api.UserCreateRequest
45

46
// UserUpdateRequest represents a request to update user profile
47
// Using the generated type from api package for automatic validation
48
type UserUpdateRequest = api.UserUpdateRequest
49

50
// PasswordResetRequest represents a request to reset user password
51
// Using the generated type from api package for automatic validation
52
type PasswordResetRequest = api.PasswordResetRequest
53

54
// ProfileResponse represents user profile data
55
type ProfileResponse struct {
56
    ID                int           `json:"id"`
57
    Username          string        `json:"username"`
58
    Email             *string       `json:"email"`
59
    Timezone          *string       `json:"timezone"`
60
    LastActive        *time.Time    `json:"last_active"`
61
    PreferredLanguage *string       `json:"preferred_language"`
62
    CurrentLevel      *string       `json:"current_level"`
63
    CreatedAt         time.Time     `json:"created_at"`
64
    UpdatedAt         time.Time     `json:"updated_at"`
65
    AIEnabled         bool          `json:"ai_enabled"`
66
    AIProvider        *string       `json:"ai_provider"`
67
    AIModel           *string       `json:"ai_model"`
68
    Roles             []models.Role `json:"roles,omitempty"`
69
    IsPaused          bool          `json:"is_paused"`
70
}
71

72
// GetAllUsers handles GET /userz - list all users (admin only) - JSON API
73
1x
func (h *UserAdminHandler) GetAllUsers(c *gin.Context) {
74
1x
    users, err := h.userService.GetAllUsers(c.Request.Context())
75
1x
    if err != nil {
76
        h.logger.Error(c.Request.Context(), "Error retrieving users", err, nil)
77
        HandleAppError(c, contextutils.WrapError(err, "failed to retrieve users"))
78
        return
79
    }
80

81
    // Convert to response format
82
1x
    var userResponses []ProfileResponse
83
1x
    for _, user := range users {
84
3x
        userResponses = append(userResponses, h.convertUserToProfileResponse(c.Request.Context(), &user))
85
3x
    }
86

87
1x
    c.JSON(http.StatusOK, gin.H{"users": userResponses})
88
}
89

90
// GetUsersPaginated handles GET /userz/paginated - list users with pagination (admin only)
91
func (h *UserAdminHandler) GetUsersPaginated(c *gin.Context) {
92
    // Parse pagination parameters
93
    page, pageSize := h.parsePagination(c)
94

95
    // Parse filters
96
    search := c.Query("search")
97
    language := c.Query("language")
98
    level := c.Query("level")
99
    aiProvider := c.Query("ai_provider")
100
    aiModel := c.Query("ai_model")
101
    aiEnabled := c.Query("ai_enabled")
102
    active := c.Query("active")
103

104
    // Get paginated users from service
105
    var users []models.User
106
    var total int
107
    var err error
108
    users, total, err = h.userService.GetUsersPaginated(
109
        c.Request.Context(),
110
        page,
111
        pageSize,
112
        search,
113
        language,
114
        level,
115
        aiProvider,
116
        aiModel,
117
        aiEnabled,
118
        active,
119
    )
120
    if err != nil {
121
        h.logger.Error(c.Request.Context(), "Error retrieving paginated users", err, map[string]interface{}{
122
            "page":      page,
123
            "page_size": pageSize,
124
            "search":    search,
125
        })
126
        HandleAppError(c, contextutils.WrapError(err, "failed to retrieve users"))
127
        return
128
    }
129

130
    // Convert to response format matching swagger specification
131
    var userItems []gin.H
132
    for _, user := range users {
133
        profileResponse := h.convertUserToProfileResponse(c.Request.Context(), &user)
134

135
        // Create user item with nested user object as per swagger spec
136
        userItem := gin.H{
137
            "user": profileResponse,
138
        }
139
        userItems = append(userItems, userItem)
140
    }
141

142
    // Calculate pagination info
143
    totalPages := (total + pageSize - 1) / pageSize
144

145
    c.JSON(http.StatusOK, gin.H{
146
        "users": userItems,
147
        "pagination": gin.H{
148
            "page":        page,
149
            "page_size":   pageSize,
150
            "total":       total,
151
            "total_pages": totalPages,
152
        },
153
    })
154
}
155

156
// parsePagination parses pagination parameters from the request
157
func (h *UserAdminHandler) parsePagination(c *gin.Context) (page, pageSize int) {
158
    page = 1
159
    pageSize = 20
160

161
    if pageStr := c.Query("page"); pageStr != "" {
162
        if p, err := strconv.Atoi(pageStr); err == nil && p > 0 {
163
            page = p
164
        }
165
    }
166

167
    if pageSizeStr := c.Query("page_size"); pageSizeStr != "" {
168
        if ps, err := strconv.Atoi(pageSizeStr); err == nil && ps > 0 && ps <= 100 {
169
            pageSize = ps
170
        }
171
    }
172

173
    return page, pageSize
174
}
175

176
// CreateUser handles POST /userz - create new user (admin only)
177
4x
func (h *UserAdminHandler) CreateUser(c *gin.Context) {
178
4x
    var req UserCreateRequest
179
4x
    if err := c.ShouldBindJSON(&req); err != nil {
180
        HandleAppError(c, contextutils.NewAppErrorWithCause(
181
            contextutils.ErrorCodeInvalidInput,
182
            contextutils.SeverityWarn,
183
            "Invalid request data",
184
            "",
185
            err,
186
        ))
187
        return
188
    }
189

190
    // Validate required fields
191
4x
    if req.Username == "" {
192
1x
        HandleAppError(c, contextutils.ErrMissingRequired)
193
1x
        return
194
1x
    }
195
3x
    if req.Password == "" {
196
1x
        HandleAppError(c, contextutils.ErrMissingRequired)
197
1x
        return
198
1x
    }
199

200
    // Extract values from generated types
201
2x
    timezone := "UTC"
202
2x
    if req.Timezone != nil && *req.Timezone != "" {
203
1x
        timezone = *req.Timezone
204
1x
        // Validate timezone if provided
205
1x
        if !h.isValidTimezone(timezone) {
206
            HandleAppError(c, contextutils.ErrInvalidFormat)
207
            return
208
        }
209
    }
210

211
2x
    preferredLanguage := "italian"
212
2x
    if req.PreferredLanguage != nil && *req.PreferredLanguage != "" {
213
1x
        preferredLanguage = *req.PreferredLanguage
214
1x
    }
215

216
2x
    currentLevel := "A1"
217
2x
    if req.CurrentLevel != nil && *req.CurrentLevel != "" {
218
1x
        currentLevel = *req.CurrentLevel
219
1x
    }
220

221
2x
    email := ""
222
2x
    if req.Email != nil {
223
2x
        email = string(*req.Email)
224
2x
    }
225

226
    // Check if username already exists
227
2x
    existingUser, err := h.userService.GetUserByUsername(c.Request.Context(), req.Username)
228
2x
    if err != nil {
229
        h.logger.Error(c.Request.Context(), "Error checking existing username", err, nil)
230
        HandleAppError(c, contextutils.WrapError(err, "failed to check existing username"))
231
        return
232
    }
233
2x
    if existingUser != nil {
234
1x
        HandleAppError(c, contextutils.ErrRecordExists)
235
1x
        return
236
1x
    }
237

238
    // Check if email already exists (if provided)
239
1x
    if email != "" {
240
1x
        existingUser, err := h.userService.GetUserByEmail(c.Request.Context(), email)
241
1x
        if err != nil {
242
            h.logger.Error(c.Request.Context(), "Error checking existing email", err, nil)
243
            HandleAppError(c, contextutils.WrapError(err, "failed to check email uniqueness"))
244
            return
245
        }
246
1x
        if existingUser != nil {
247
            HandleAppError(c, contextutils.ErrRecordExists)
248
            return
249
        }
250
    }
251

252
    // Create user
253
1x
    user, err := h.userService.CreateUserWithEmailAndTimezone(
254
1x
        c.Request.Context(),
255
1x
        req.Username,
256
1x
        email,
257
1x
        timezone,
258
1x
        preferredLanguage,
259
1x
        currentLevel,
260
1x
    )
261
1x
    if err != nil {
262
        h.logger.Error(c.Request.Context(), "Error creating user", err, nil)
263
        HandleAppError(c, contextutils.WrapError(err, "failed to create user"))
264
        return
265
    }
266

267
    // Set password
268
1x
    err = h.userService.UpdateUserPassword(c.Request.Context(), user.ID, req.Password)
269
1x
    if err != nil {
270
        h.logger.Error(c.Request.Context(), "Error setting user password", err, nil)
271
        // Try to clean up the created user
272
        _ = h.userService.DeleteUser(c.Request.Context(), user.ID)
273
        HandleAppError(c, contextutils.WrapError(err, "failed to set user password"))
274
        return
275
    }
276

277
    // Return the created user profile
278
1x
    c.JSON(http.StatusCreated, gin.H{
279
1x
        "message": "User created successfully",
280
1x
        "user":    h.convertUserToProfileResponse(c.Request.Context(), user),
281
1x
    })
282
}
283

284
// UpdateUser handles PUT /userz/:id - update user details (admin or self)
285
1x
func (h *UserAdminHandler) UpdateUser(c *gin.Context) {
286
1x
    userIDStr := c.Param("id")
287
1x
    userID, err := strconv.Atoi(userIDStr)
288
1x
    if err != nil {
289
        HandleAppError(c, contextutils.ErrInvalidFormat)
290
        return
291
    }
292

293
    // Check if user exists
294
1x
    user, err := h.userService.GetUserByID(c.Request.Context(), userID)
295
1x
    if err != nil {
296
        h.logger.Error(c.Request.Context(), "Error retrieving user", err, nil)
297
        HandleAppError(c, contextutils.WrapError(err, "database error"))
298
        return
299
    }
300
1x
    if user == nil {
301
        HandleAppError(c, contextutils.ErrRecordNotFound)
302
        return
303
    }
304

305
    // Check authorization (admin or self) - skip for direct routes (testing)
306
1x
    if currentUserID, err := GetCurrentUserID(c); err == nil {
307
1x
        if err := RequireSelfOrAdmin(c.Request.Context(), h.userService, currentUserID, userID); err != nil {
308
            if contextutils.IsError(err, contextutils.ErrForbidden) {
309
                HandleAppError(c, contextutils.ErrForbidden)
310
                return
311
            }
312
            h.logger.Error(c.Request.Context(), "Error checking authorization", err, nil)
313
            HandleAppError(c, contextutils.WrapError(err, "failed to check authorization"))
314
            return
315
        }
316
    }
317

318
1x
    var req UserUpdateRequest
319
1x
    if err := c.ShouldBindJSON(&req); err != nil {
320
        HandleAppError(c, contextutils.NewAppErrorWithCause(
321
            contextutils.ErrorCodeInvalidInput,
322
            contextutils.SeverityWarn,
323
            "Invalid request data",
324
            "",
325
            err,
326
        ))
327
        return
328
    }
329

330
    // Validate timezone if provided
331
1x
    if req.Timezone != nil && *req.Timezone != "" && !h.isValidTimezone(*req.Timezone) {
332
        HandleAppError(c, contextutils.ErrInvalidFormat)
333
        return
334
    }
335

336
    // Use existing values if not provided in request
337
1x
    username := user.Username
338
1x
    if req.Username != nil && *req.Username != "" {
339
1x
        username = *req.Username
340
1x
    }
341

342
1x
    email := ""
343
1x
    if user.Email.Valid {
344
1x
        email = user.Email.String
345
1x
    }
346
1x
    if req.Email != nil {
347
1x
        email = string(*req.Email)
348
1x
    }
349

350
1x
    timezone := ""
351
1x
    if user.Timezone.Valid {
352
1x
        timezone = user.Timezone.String
353
1x
    }
354
1x
    if req.Timezone != nil && *req.Timezone != "" {
355
1x
        timezone = *req.Timezone
356
1x
    }
357

358
1x
    preferredLanguage := ""
359
1x
    if user.PreferredLanguage.Valid {
360
1x
        preferredLanguage = user.PreferredLanguage.String
361
1x
    }
362
1x
    if req.PreferredLanguage != nil && *req.PreferredLanguage != "" {
363
        preferredLanguage = *req.PreferredLanguage
364
    }
365

366
1x
    currentLevel := ""
367
1x
    if user.CurrentLevel.Valid {
368
1x
        currentLevel = user.CurrentLevel.String
369
1x
    }
370
1x
    if req.CurrentLevel != nil && *req.CurrentLevel != "" {
371
        currentLevel = *req.CurrentLevel
372
    }
373

374
    // Check if new username already exists (if changed)
375
1x
    if username != user.Username {
376
1x
        existingUser, err := h.userService.GetUserByUsername(c.Request.Context(), username)
377
1x
        if err != nil {
378
            h.logger.Error(c.Request.Context(), "Error checking existing username", err, nil)
379
            HandleAppError(c, contextutils.WrapError(err, "failed to check username uniqueness"))
380
            return
381
        }
382
1x
        if existingUser != nil {
383
            HandleAppError(c, contextutils.ErrRecordExists)
384
            return
385
        }
386
    }
387

388
    // Check if new email already exists (if changed)
389
1x
    if email != "" && user.Email.Valid && email != user.Email.String {
390
1x
        existingUser, err := h.userService.GetUserByEmail(c.Request.Context(), email)
391
1x
        if err != nil {
392
            h.logger.Error(c.Request.Context(), "Error checking existing email", err, nil)
393
            HandleAppError(c, contextutils.WrapError(err, "failed to check email uniqueness"))
394
            return
395
        }
396
1x
        if existingUser != nil {
397
            HandleAppError(c, contextutils.ErrRecordExists)
398
            return
399
        }
400
    }
401

402
    // Update user profile
403
1x
    err = h.userService.UpdateUserProfile(c.Request.Context(), userID, username, email, timezone)
404
1x
    if err != nil {
405
        h.logger.Error(c.Request.Context(), "Error updating user profile", err, nil)
406

407
        // Check if the error is due to user not found
408
        if errors.Is(err, contextutils.ErrRecordNotFound) {
409
            HandleAppError(c, contextutils.ErrRecordNotFound)
410
            return
411
        }
412

413
        HandleAppError(c, contextutils.WrapError(err, "failed to update user profile"))
414
        return
415
    }
416

417
    // Handle AI settings update if provided
418
1x
    needsAIUpdate := req.AiEnabled != nil || (req.AiProvider != nil && *req.AiProvider != "") || (req.AiModel != nil && *req.AiModel != "") || (req.ApiKey != nil && *req.ApiKey != "")
419
1x
    if needsAIUpdate {
420
        // Prepare AI settings
421
        aiSettings := &models.UserSettings{
422
            Language:  preferredLanguage,
423
            Level:     currentLevel,
424
            AIEnabled: req.AiEnabled != nil && *req.AiEnabled,
425
        }
426

427
        // Set AI provider and model
428
        if req.AiProvider != nil && *req.AiProvider != "" {
429
            aiSettings.AIProvider = *req.AiProvider
430
        } else if user.AIProvider.Valid {
431
            aiSettings.AIProvider = user.AIProvider.String
432
        }
433

434
        if req.AiModel != nil && *req.AiModel != "" {
435
            aiSettings.AIModel = *req.AiModel
436
        } else if user.AIModel.Valid {
437
            aiSettings.AIModel = user.AIModel.String
438
        }
439

440
        // Set API key if provided
441
        if req.ApiKey != nil && *req.ApiKey != "" {
442
            aiSettings.AIAPIKey = *req.ApiKey
443
        }
444

445
        // Update AI settings
446
        err = h.userService.UpdateUserSettings(c.Request.Context(), userID, aiSettings)
447
        if err != nil {
448
            h.logger.Error(c.Request.Context(), "Error updating user AI settings", err, nil)
449

450
            // Check if the error is due to user not found
451
            if errors.Is(err, contextutils.ErrRecordNotFound) {
452
                HandleAppError(c, contextutils.ErrRecordNotFound)
453
                return
454
            }
455

456
            HandleAppError(c, contextutils.WrapError(err, "failed to update AI settings"))
457
            return
458
        }
459
    }
460

461
    // Handle role updates if provided
462
1x
    if req.SelectedRoles != nil {
463
        // Get current user roles
464
        currentRoles, err := h.userService.GetUserRoles(c.Request.Context(), userID)
465
        if err != nil {
466
            h.logger.Error(c.Request.Context(), "Error getting current user roles", err, nil)
467
            HandleAppError(c, contextutils.WrapError(err, "failed to get current user roles"))
468
            return
469
        }
470

471
        // Get all available roles
472
        allRoles, err := h.userService.GetAllRoles(c.Request.Context())
473
        if err != nil {
474
            h.logger.Error(c.Request.Context(), "Error getting all roles", err, nil)
475
            HandleAppError(c, contextutils.WrapError(err, "failed to get available roles"))
476
            return
477
        }
478

479
        // Create maps for efficient lookup
480
        currentRoleNames := make(map[string]bool)
481
        for _, role := range currentRoles {
482
            currentRoleNames[role.Name] = true
483
        }
484

485
        requestedRoleNames := make(map[string]bool)
486
        for _, roleName := range *req.SelectedRoles {
487
            requestedRoleNames[roleName] = true
488
        }
489

490
        // Find roles to add and remove
491
        for _, roleName := range *req.SelectedRoles {
492
            if !currentRoleNames[roleName] {
493
                // Find role by name
494
                var roleToAdd *models.Role
495
                for _, role := range allRoles {
496
                    if role.Name == roleName {
497
                        roleToAdd = &role
498
                        break
499
                    }
500
                }
501
                if roleToAdd != nil {
502
                    err = h.userService.AssignRole(c.Request.Context(), userID, roleToAdd.ID)
503
                    if err != nil {
504
                        h.logger.Error(c.Request.Context(), "Error assigning role to user", err, map[string]interface{}{
505
                            "user_id":   userID,
506
                            "role_id":   roleToAdd.ID,
507
                            "role_name": roleName,
508
                        })
509
                        HandleAppError(c, contextutils.WrapError(err, "failed to assign role"))
510
                        return
511
                    }
512
                }
513
            }
514
        }
515

516
        // Remove roles that are no longer selected
517
        for _, role := range currentRoles {
518
            if !requestedRoleNames[role.Name] {
519
                err = h.userService.RemoveRole(c.Request.Context(), userID, role.ID)
520
                if err != nil {
521
                    h.logger.Error(c.Request.Context(), "Error removing role from user", err, map[string]interface{}{
522
                        "user_id":   userID,
523
                        "role_id":   role.ID,
524
                        "role_name": role.Name,
525
                    })
526
                    HandleAppError(c, contextutils.WrapError(err, "failed to remove role"))
527
                    return
528
                }
529
            }
530
        }
531
    }
532

533
    // Get updated user
534
1x
    updatedUser, err := h.userService.GetUserByID(c.Request.Context(), userID)
535
1x
    if err != nil {
536
        h.logger.Error(c.Request.Context(), "Error retrieving updated user", err, nil)
537
        HandleAppError(c, contextutils.WrapError(err, "failed to retrieve updated user"))
538
        return
539
    }
540

541
1x
    c.JSON(http.StatusOK, gin.H{
542
1x
        "message": "User updated successfully",
543
1x
        "user":    h.convertUserToProfileResponse(c.Request.Context(), updatedUser),
544
1x
    })
545
}
546

547
// DeleteUser handles DELETE /userz/:id - delete user (admin only)
548
1x
func (h *UserAdminHandler) DeleteUser(c *gin.Context) {
549
1x
    userIDStr := c.Param("id")
550
1x
    userID, err := strconv.Atoi(userIDStr)
551
1x
    if err != nil {
552
        HandleAppError(c, contextutils.ErrInvalidFormat)
553
        return
554
    }
555

556
    // Check if user exists
557
1x
    user, err := h.userService.GetUserByID(c.Request.Context(), userID)
558
1x
    if err != nil {
559
        h.logger.Error(c.Request.Context(), "Error retrieving user", err, nil)
560
        HandleAppError(c, contextutils.WrapError(err, "database error"))
561
        return
562
    }
563
1x
    if user == nil {
564
        HandleAppError(c, contextutils.ErrRecordNotFound)
565
        return
566
    }
567

568
    // Delete user
569
1x
    err = h.userService.DeleteUser(c.Request.Context(), userID)
570
1x
    if err != nil {
571
        h.logger.Error(c.Request.Context(), "Error deleting user", err, nil)
572
        HandleAppError(c, contextutils.WrapError(err, "failed to delete user"))
573
        return
574
    }
575

576
1x
    c.JSON(http.StatusOK, gin.H{"message": "User deleted successfully"})
577
}
578

579
// ResetUserPassword handles POST /userz/:id/reset-password - reset user password (admin only)
580
1x
func (h *UserAdminHandler) ResetUserPassword(c *gin.Context) {
581
1x
    userIDStr := c.Param("id")
582
1x
    userID, err := strconv.Atoi(userIDStr)
583
1x
    if err != nil {
584
        HandleAppError(c, contextutils.ErrInvalidFormat)
585
        return
586
    }
587

588
    // Check if user exists
589
1x
    user, err := h.userService.GetUserByID(c.Request.Context(), userID)
590
1x
    if err != nil {
591
        h.logger.Error(c.Request.Context(), "Error retrieving user", err, map[string]interface{}{"user_id": userID})
592
        HandleAppError(c, contextutils.WrapError(err, "database error"))
593
        return
594
    }
595
1x
    if user == nil {
596
        h.logger.Warn(c.Request.Context(), "User not found for password reset", map[string]interface{}{"user_id": userID})
597
        HandleAppError(c, contextutils.ErrRecordNotFound)
598
        return
599
    }
600

601
1x
    var req PasswordResetRequest
602
1x
    if err := c.ShouldBindJSON(&req); err != nil {
603
        h.logger.Error(c.Request.Context(), "Invalid request data for password reset", err, map[string]interface{}{"user_id": userID})
604
        HandleAppError(c, contextutils.NewAppErrorWithCause(
605
            contextutils.ErrorCodeInvalidInput,
606
            contextutils.SeverityWarn,
607
            "Invalid request data",
608
            "",
609
            err,
610
        ))
611
        return
612
    }
613

614
    // Validate password
615
1x
    if req.NewPassword == "" {
616
        HandleAppError(c, contextutils.ErrMissingRequired)
617
        return
618
    }
619

620
    // Update password
621
1x
    err = h.userService.UpdateUserPassword(c.Request.Context(), userID, req.NewPassword)
622
1x
    if err != nil {
623
        h.logger.Error(c.Request.Context(), "Error updating user password", err, map[string]interface{}{"user_id": userID})
624
        HandleAppError(c, contextutils.WrapError(err, "failed to update password"))
625
        return
626
    }
627

628
1x
    h.logger.Info(c.Request.Context(), "Password reset successful", map[string]interface{}{"user_id": userID, "username": user.Username})
629
1x
    c.JSON(http.StatusOK, gin.H{"message": "Password reset successfully"})
630
}
631

632
// UpdateCurrentUserProfile handles PUT /userz/profile - update current user profile
633
2x
func (h *UserAdminHandler) UpdateCurrentUserProfile(c *gin.Context) {
634
2x
    // Get user ID from context/session
635
2x
    userID, err := GetCurrentUserID(c)
636
2x
    if err != nil {
637
        HandleAppError(c, contextutils.ErrUnauthorized)
638
        return
639
    }
640

641
2x
    var req UserUpdateRequest
642
2x
    if err := c.ShouldBindJSON(&req); err != nil {
643
        HandleAppError(c, contextutils.NewAppErrorWithCause(
644
            contextutils.ErrorCodeInvalidInput,
645
            contextutils.SeverityWarn,
646
            "Invalid request data",
647
            "",
648
            err,
649
        ))
650
        return
651
    }
652

653
    // Validate timezone if provided
654
2x
    if req.Timezone != nil && *req.Timezone != "" && !h.isValidTimezone(*req.Timezone) {
655
        HandleAppError(c, contextutils.ErrInvalidFormat)
656
        return
657
    }
658

659
    // Email validation is handled automatically by openapi_types.Email
660

661
    // Get current user
662
2x
    user, err := h.userService.GetUserByID(c.Request.Context(), userID)
663
2x
    if err != nil {
664
        h.logger.Error(c.Request.Context(), "Error retrieving user", err, nil)
665
        HandleAppError(c, contextutils.WrapError(err, "database error"))
666
        return
667
    }
668
2x
    if user == nil {
669
        HandleAppError(c, contextutils.ErrRecordNotFound)
670
        return
671
    }
672

673
    // Check authorization (self-only for this endpoint)
674
2x
    if err := RequireSelfOrAdmin(c.Request.Context(), h.userService, userID, userID); err != nil {
675
        if contextutils.IsError(err, contextutils.ErrForbidden) {
676
            HandleAppError(c, contextutils.ErrForbidden)
677
            return
678
        }
679
        h.logger.Error(c.Request.Context(), "Error checking authorization", err, nil)
680
        HandleAppError(c, contextutils.WrapError(err, "failed to check authorization"))
681
        return
682
    }
683

684
    // Use existing values if not provided in request
685
2x
    username := user.Username
686
2x
    if req.Username != nil && *req.Username != "" {
687
2x
        username = *req.Username
688
2x
    }
689

690
2x
    email := ""
691
2x
    if user.Email.Valid {
692
        email = user.Email.String
693
    }
694
2x
    if req.Email != nil {
695
1x
        email = string(*req.Email)
696
1x
    }
697

698
2x
    timezone := ""
699
2x
    if user.Timezone.Valid {
700
2x
        timezone = user.Timezone.String
701
2x
    }
702
2x
    if req.Timezone != nil && *req.Timezone != "" {
703
2x
        timezone = *req.Timezone
704
2x
    }
705

706
    // Check if new username already exists (if changed)
707
2x
    if username != user.Username {
708
        existingUser, err := h.userService.GetUserByUsername(c.Request.Context(), username)
709
        if err != nil {
710
            h.logger.Error(c.Request.Context(), "Error checking existing username", err, nil)
711
            HandleAppError(c, contextutils.WrapError(err, "failed to check username uniqueness"))
712
            return
713
        }
714
        if existingUser != nil {
715
            HandleAppError(c, contextutils.ErrRecordExists)
716
            return
717
        }
718
    }
719

720
    // Check if new email already exists (if changed)
721
2x
    if email != "" && user.Email.Valid && email != user.Email.String {
722
        existingUser, err := h.userService.GetUserByEmail(c.Request.Context(), email)
723
        if err != nil {
724
            h.logger.Error(c.Request.Context(), "Error checking existing email", err, nil)
725
            HandleAppError(c, contextutils.WrapError(err, "failed to check email uniqueness"))
726
            return
727
        }
728
        if existingUser != nil {
729
            HandleAppError(c, contextutils.ErrRecordExists)
730
            return
731
        }
732
    }
733

734
    // Use existing AI values if not provided in request
735
2x
    preferredLanguage := ""
736
2x
    if user.PreferredLanguage.Valid {
737
2x
        preferredLanguage = user.PreferredLanguage.String
738
2x
    }
739
2x
    if req.PreferredLanguage != nil && *req.PreferredLanguage != "" {
740
2x
        preferredLanguage = *req.PreferredLanguage
741
2x
    }
742

743
2x
    currentLevel := ""
744
2x
    if user.CurrentLevel.Valid {
745
2x
        currentLevel = user.CurrentLevel.String
746
2x
    }
747
2x
    if req.CurrentLevel != nil && *req.CurrentLevel != "" {
748
2x
        currentLevel = *req.CurrentLevel
749
2x
    }
750

751
    // Update user profile
752
2x
    err = h.userService.UpdateUserProfile(c.Request.Context(), userID, username, email, timezone)
753
2x
    if err != nil {
754
        h.logger.Error(c.Request.Context(), "Error updating user profile", err, nil)
755
        HandleAppError(c, contextutils.WrapError(err, "failed to update user profile"))
756
        return
757
    }
758

759
    // Handle AI settings update if provided
760
2x
    needsAIUpdate := req.AiEnabled != nil || (req.AiProvider != nil && *req.AiProvider != "") || (req.AiModel != nil && *req.AiModel != "") || (req.PreferredLanguage != nil && *req.PreferredLanguage != "") || (req.CurrentLevel != nil && *req.CurrentLevel != "") || (req.ApiKey != nil && *req.ApiKey != "")
761
2x

762
2x
    if needsAIUpdate {
763
2x
        aiSettings := &models.UserSettings{
764
2x
            Language:  preferredLanguage,
765
2x
            Level:     currentLevel,
766
2x
            AIEnabled: req.AiEnabled != nil && *req.AiEnabled,
767
2x
        }
768
2x

769
2x
        if req.AiProvider != nil && *req.AiProvider != "" {
770
1x
            aiSettings.AIProvider = *req.AiProvider
771
1x
        } else if user.AIProvider.Valid {
772
            aiSettings.AIProvider = user.AIProvider.String
773
        }
774

775
2x
        if req.AiModel != nil && *req.AiModel != "" {
776
1x
            aiSettings.AIModel = *req.AiModel
777
1x
        } else if user.AIModel.Valid {
778
            aiSettings.AIModel = user.AIModel.String
779
        }
780

781
2x
        if req.ApiKey != nil && *req.ApiKey != "" {
782
1x
            aiSettings.AIAPIKey = *req.ApiKey
783
1x
        }
784

785
2x
        err = h.userService.UpdateUserSettings(c.Request.Context(), userID, aiSettings)
786
2x
        if err != nil {
787
            h.logger.Error(c.Request.Context(), "Error updating user AI settings", err, nil)
788
            HandleAppError(c, contextutils.WrapError(err, "failed to update AI settings"))
789
            return
790
        }
791
    }
792

793
    // Get updated user
794
2x
    updatedUser, err := h.userService.GetUserByID(c.Request.Context(), userID)
795
2x
    if err != nil {
796
        h.logger.Error(c.Request.Context(), "Error retrieving updated user", err, nil)
797
        HandleAppError(c, contextutils.WrapError(err, "failed to retrieve updated profile"))
798
        return
799
    }
800

801
2x
    c.JSON(http.StatusOK, gin.H{
802
2x
        "message": "Profile updated successfully",
803
2x
        "user":    h.convertUserToProfileResponse(c.Request.Context(), updatedUser),
804
2x
    })
805
}
806

807
// isUserPaused checks if a user is paused by checking the worker_settings table
808
7x
func (h *UserAdminHandler) isUserPaused(ctx context.Context, userID int) bool {
809
7x
    query := `SELECT setting_value FROM worker_settings WHERE setting_key = $1`
810
7x
    var value string
811
7x
    settingKey := fmt.Sprintf("user_pause_%d", userID)
812
7x

813
7x
    err := h.userService.GetDB().QueryRowContext(ctx, query, settingKey).Scan(&value)
814
7x
    if err != nil {
815
7x
        // If no setting exists, user is not paused
816
7x
        if errors.Is(err, sql.ErrNoRows) {
817
7x
            return false
818
7x
        }
819
        // Log error but don't fail - default to not paused
820
        h.logger.Warn(ctx, "Failed to check user pause status", map[string]interface{}{
821
            "user_id": userID,
822
            "error":   err.Error(),
823
        })
824
        return false
825
    }
826

827
    return value == "true"
828
}
829

830
// Helper functions
831

832
// convertUserToProfileResponse converts a User model to ProfileResponse
833
7x
func (h *UserAdminHandler) convertUserToProfileResponse(ctx context.Context, user *models.User) ProfileResponse {
834
7x
    // Get user roles
835
7x
    roles, err := h.userService.GetUserRoles(ctx, user.ID)
836
7x
    if err != nil {
837
        // Log error but don't fail the response
838
        h.logger.Warn(ctx, "Failed to get user roles", map[string]interface{}{
839
            "user_id": user.ID,
840
            "error":   err.Error(),
841
        })
842
        roles = []models.Role{}
843
    }
844

845
7x
    return ProfileResponse{
846
7x
        ID:                user.ID,
847
7x
        Username:          user.Username,
848
7x
        Email:             nullStringToPointer(user.Email),
849
7x
        Timezone:          nullStringToPointer(user.Timezone),
850
7x
        LastActive:        nullTimeToPointer(user.LastActive),
851
7x
        PreferredLanguage: nullStringToPointer(user.PreferredLanguage),
852
7x
        CurrentLevel:      nullStringToPointer(user.CurrentLevel),
853
7x
        CreatedAt:         user.CreatedAt,
854
7x
        UpdatedAt:         user.UpdatedAt,
855
7x
        AIEnabled:         user.AIEnabled.Valid && user.AIEnabled.Bool,
856
7x
        AIProvider:        nullStringToPointer(user.AIProvider),
857
7x
        AIModel:           nullStringToPointer(user.AIModel),
858
7x
        Roles:             roles,
859
7x
        IsPaused:          h.isUserPaused(ctx, user.ID),
860
7x
    }
861
}
862

863
// isValidTimezone checks if a timezone string is valid
864
4x
func (h *UserAdminHandler) isValidTimezone(tz string) bool {
865
4x
    // Common timezone validation - check if it can be loaded
866
4x
    _, err := time.LoadLocation(tz)
867
4x
    if err != nil {
868
        // Also allow UTC as fallback
869
        return strings.ToUpper(tz) == "UTC"
870
    }
871
4x
    return true
872
}
873

874
// Helper function to convert sql.NullString to *string (if not already available)
875
42x
func nullStringToPointer(ns sql.NullString) *string {
876
42x
    if ns.Valid {
877
31x
        return &ns.String
878
31x
    }
879
11x
    return nil
880
}
881

882
// Helper function to convert sql.NullTime to *time.Time (if not already available)
883
7x
func nullTimeToPointer(nt sql.NullTime) *time.Time {
884
7x
    if nt.Valid {
885
7x
        return &nt.Time
886
7x
    }
887
    return nil
888
}
889


			
quizapp internal handlers worker_admin_handler.go
67.0%
Statements
61/91
1
package handlers
2

3
import (
4
    "embed"
5
    "encoding/json"
6
    "fmt"
7
    "net/http"
8
    "strings"
9

10
    "quizapp/internal/observability"
11
    contextutils "quizapp/internal/utils"
12

13
    "github.com/gin-gonic/gin"
14
    "go.opentelemetry.io/otel/attribute"
15
)
16

17
//go:embed data/verb-conjugations
18
var verbConjugationFS embed.FS
19

20
// VerbConjugationHandler handles verb conjugation related HTTP requests
21
type VerbConjugationHandler struct {
22
    logger *observability.Logger
23
}
24

25
// NewVerbConjugationHandler creates a new VerbConjugationHandler instance
26
13x
func NewVerbConjugationHandler(logger *observability.Logger) *VerbConjugationHandler {
27
13x
    return &VerbConjugationHandler{
28
13x
        logger: logger,
29
13x
    }
30
13x
}
31

32
// VerbConjugationData represents the complete verb conjugation data for a language
33
type VerbConjugationData struct {
34
    Language     string            `json:"language"`
35
    LanguageName string            `json:"languageName"`
36
    Verbs        []VerbConjugation `json:"verbs"`
37
}
38

39
// VerbConjugation represents a single verb with its conjugations across all tenses
40
type VerbConjugation struct {
41
    Language     string  `json:"language"`
42
    LanguageName string  `json:"languageName"`
43
    Infinitive   string  `json:"infinitive"`
44
    InfinitiveEn string  `json:"infinitiveEn"`
45
    Slug         string  `json:"slug,omitempty"` // Optional ASCII slug for filename when infinitive has Unicode combining characters
46
    Category     string  `json:"category"`
47
    Tenses       []Tense `json:"tenses"`
48
}
49

50
// Tense represents a grammatical tense with its conjugations and description
51
type Tense struct {
52
    TenseID      string        `json:"tenseId"`
53
    TenseName    string        `json:"tenseName"`
54
    TenseNameEn  string        `json:"tenseNameEn"`
55
    Description  string        `json:"description"`
56
    Conjugations []Conjugation `json:"conjugations"`
57
}
58

59
// Conjugation represents a single conjugated form with example sentence
60
type Conjugation struct {
61
    Pronoun           string `json:"pronoun"`
62
    Form              string `json:"form"`
63
    ExampleSentence   string `json:"exampleSentence"`
64
    ExampleSentenceEn string `json:"exampleSentenceEn"`
65
}
66

67
// VerbConjugationInfo represents metadata about the verb conjugation section
68
type VerbConjugationInfo struct {
69
    ID          string `json:"id"`
70
    Name        string `json:"name"`
71
    Emoji       string `json:"emoji"`
72
    Description string `json:"description"`
73
}
74

75
// GetVerbConjugationInfo returns metadata about verb conjugations
76
1x
func (h *VerbConjugationHandler) GetVerbConjugationInfo(c *gin.Context) {
77
1x
    _, span := observability.TraceHandlerFunction(c.Request.Context(), "get_verb_conjugation_info")
78
1x
    defer observability.FinishSpan(span, nil)
79
1x

80
1x
    data, err := verbConjugationFS.ReadFile("data/verb-conjugations/info.json")
81
1x
    if err != nil {
82
        h.logger.Error(c.Request.Context(), "Failed to read verb conjugation info", err)
83
        HandleAppError(c, contextutils.WrapError(err, "failed to read verb conjugation info"))
84
        return
85
    }
86

87
1x
    var info VerbConjugationInfo
88
1x
    if err := json.Unmarshal(data, &info); err != nil {
89
        h.logger.Error(c.Request.Context(), "Failed to parse verb conjugation info", err)
90
        HandleAppError(c, contextutils.WrapError(err, "failed to parse verb conjugation info"))
91
        return
92
    }
93

94
1x
    c.JSON(http.StatusOK, info)
95
}
96

97
// GetVerbConjugations returns all verbs for a specific language
98
10x
func (h *VerbConjugationHandler) GetVerbConjugations(c *gin.Context) {
99
10x
    _, span := observability.TraceHandlerFunction(c.Request.Context(), "get_verb_conjugations")
100
10x
    defer observability.FinishSpan(span, nil)
101
10x

102
10x
    languageCode := c.Param("language")
103
10x
    if languageCode == "" {
104
        HandleAppError(c, contextutils.ErrMissingRequired)
105
        return
106
    }
107

108
10x
    span.SetAttributes(attribute.String("language", languageCode))
109
10x

110
10x
    // Read all verb files in the language directory
111
10x
    languageDir := fmt.Sprintf("data/verb-conjugations/%s", languageCode)
112
10x
    entries, err := verbConjugationFS.ReadDir(languageDir)
113
10x
    if err != nil {
114
1x
        // Check if it's a directory not found error
115
1x
        if strings.Contains(err.Error(), "file does not exist") || strings.Contains(err.Error(), "no such file") || strings.Contains(err.Error(), "not found") {
116
1x
            HandleAppError(c, contextutils.ErrRecordNotFound)
117
1x
            return
118
1x
        }
119
        h.logger.Error(c.Request.Context(), "Failed to read verb conjugation directory", err, map[string]interface{}{
120
            "language":  languageCode,
121
            "directory": languageDir,
122
        })
123
        HandleAppError(c, contextutils.WrapError(err, "failed to read verb conjugation directory"))
124
        return
125
    }
126

127
9x
    var verbs []VerbConjugation
128
9x
    var languageName string
129
9x
    var language string
130
9x

131
9x
    // Read each verb file
132
9x
    for _, entry := range entries {
133
76x
        if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".json") {
134
76x
            filename := fmt.Sprintf("%s/%s", languageDir, entry.Name())
135
76x
            data, err := verbConjugationFS.ReadFile(filename)
136
76x
            if err != nil {
137
                h.logger.Error(c.Request.Context(), "Failed to read verb file", err, map[string]interface{}{
138
                    "language": languageCode,
139
                    "filename": filename,
140
                })
141
                HandleAppError(c, contextutils.WrapError(err, "failed to read verb file"))
142
                return
143
            }
144

145
76x
            var verb VerbConjugation
146
76x
            if err := json.Unmarshal(data, &verb); err != nil {
147
                h.logger.Error(c.Request.Context(), "Failed to parse verb file", err, map[string]interface{}{
148
                    "language": languageCode,
149
                    "filename": filename,
150
                })
151
                HandleAppError(c, contextutils.WrapError(err, "failed to parse verb file"))
152
                return
153
            }
154

155
            // Set language metadata from first verb (all verbs in a directory should have the same language)
156
76x
            if languageName == "" {
157
9x
                languageName = verb.LanguageName
158
9x
                language = verb.Language
159
9x
            }
160

161
76x
            verbs = append(verbs, verb)
162
        }
163
    }
164

165
9x
    if len(verbs) == 0 {
166
        HandleAppError(c, contextutils.ErrRecordNotFound)
167
        return
168
    }
169

170
9x
    verbData := VerbConjugationData{
171
9x
        Language:     language,
172
9x
        LanguageName: languageName,
173
9x
        Verbs:        verbs,
174
9x
    }
175
9x

176
9x
    c.JSON(http.StatusOK, verbData)
177
}
178

179
// GetVerbConjugation returns a specific verb's conjugations for a language
180
4x
func (h *VerbConjugationHandler) GetVerbConjugation(c *gin.Context) {
181
4x
    _, span := observability.TraceHandlerFunction(c.Request.Context(), "get_verb_conjugation")
182
4x
    defer observability.FinishSpan(span, nil)
183
4x

184
4x
    languageCode := c.Param("language")
185
4x
    verbInfinitive := c.Param("verb")
186
4x

187
4x
    if languageCode == "" || verbInfinitive == "" {
188
        HandleAppError(c, contextutils.ErrMissingRequired)
189
        return
190
    }
191

192
4x
    span.SetAttributes(attribute.String("language", languageCode))
193
4x
    span.SetAttributes(attribute.String("verb", verbInfinitive))
194
4x

195
4x
    // Read the specific verb file
196
4x
    filename := fmt.Sprintf("data/verb-conjugations/%s/%s.json", languageCode, verbInfinitive)
197
4x
    data, err := verbConjugationFS.ReadFile(filename)
198
4x
    if err != nil {
199
2x
        // Check if it's a file not found error
200
2x
        if strings.Contains(err.Error(), "file does not exist") || strings.Contains(err.Error(), "no such file") || strings.Contains(err.Error(), "not found") {
201
2x
            HandleAppError(c, contextutils.ErrRecordNotFound)
202
2x
            return
203
2x
        }
204
        h.logger.Error(c.Request.Context(), "Failed to read verb file", err, map[string]interface{}{
205
            "language": languageCode,
206
            "verb":     verbInfinitive,
207
            "filename": filename,
208
        })
209
        HandleAppError(c, contextutils.WrapError(err, "failed to read verb file"))
210
        return
211
    }
212

213
2x
    var verb VerbConjugation
214
2x
    if err := json.Unmarshal(data, &verb); err != nil {
215
        h.logger.Error(c.Request.Context(), "Failed to parse verb file", err, map[string]interface{}{
216
            "language": languageCode,
217
            "verb":     verbInfinitive,
218
        })
219
        HandleAppError(c, contextutils.WrapError(err, "failed to parse verb file"))
220
        return
221
    }
222

223
2x
    c.JSON(http.StatusOK, verb)
224
}
225

226
// GetAvailableLanguages returns the list of available languages for verb conjugations
227
1x
func (h *VerbConjugationHandler) GetAvailableLanguages(c *gin.Context) {
228
1x
    _, span := observability.TraceHandlerFunction(c.Request.Context(), "get_available_languages")
229
1x
    defer observability.FinishSpan(span, nil)
230
1x

231
1x
    // Read all entries in the verb-conjugations directory
232
1x
    entries, err := verbConjugationFS.ReadDir("data/verb-conjugations")
233
1x
    if err != nil {
234
        h.logger.Error(c.Request.Context(), "Failed to read verb conjugation directory", err)
235
        HandleAppError(c, contextutils.WrapError(err, "failed to read verb conjugation directory"))
236
        return
237
    }
238

239
1x
    var languages []string
240
1x
    for _, entry := range entries {
241
9x
        // Only include directories (language folders), skip files like info.json
242
9x
        if entry.IsDir() {
243
8x
            languages = append(languages, entry.Name())
244
8x
        }
245
    }
246

247
1x
    c.JSON(http.StatusOK, languages)
248
}
249


			
quizapp internal handlers worker_admin_handler.go
58.7%
Statements
61/104
1
package handlers
2

3
import (
4
    "context"
5
    "fmt"
6
    "html/template"
7
    "net/http"
8
    "strings"
9
    "time"
10

11
    "quizapp/internal/config"
12
    "quizapp/internal/models"
13
    "quizapp/internal/observability"
14
    "quizapp/internal/services"
15
    contextutils "quizapp/internal/utils"
16

17
    "github.com/gin-gonic/gin"
18
    "go.opentelemetry.io/otel/attribute"
19
)
20

21
// WordOfTheDayHandler handles word of the day HTTP requests
22
type WordOfTheDayHandler struct {
23
    userService         services.UserServiceInterface
24
    wordOfTheDayService services.WordOfTheDayServiceInterface
25
    cfg                 *config.Config
26
    logger              *observability.Logger
27
}
28

29
// NewWordOfTheDayHandler creates a new WordOfTheDayHandler
30
func NewWordOfTheDayHandler(
31
    userService services.UserServiceInterface,
32
    wordOfTheDayService services.WordOfTheDayServiceInterface,
33
    cfg *config.Config,
34
    logger *observability.Logger,
35
13x
) *WordOfTheDayHandler {
36
13x
    return &WordOfTheDayHandler{
37
13x
        userService:         userService,
38
13x
        wordOfTheDayService: wordOfTheDayService,
39
13x
        cfg:                 cfg,
40
13x
        logger:              logger,
41
13x
    }
42
13x
}
43

44
// ParseDateInUserTimezone parses a date string in the user's timezone
45
9x
func (h *WordOfTheDayHandler) ParseDateInUserTimezone(ctx context.Context, userID int, dateStr string) (time.Time, string, error) {
46
9x
    // Delegate to shared util with injected user lookup
47
9x
    return contextutils.ParseDateInUserTimezone(ctx, userID, dateStr, h.userService.GetUserByID)
48
9x
}
49

50
// GetWordOfTheDay handles GET /v1/word-of-day/:date
51
3x
func (h *WordOfTheDayHandler) GetWordOfTheDay(c *gin.Context) {
52
3x
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "get_word_of_the_day")
53
3x
    defer observability.FinishSpan(span, nil)
54
3x

55
3x
    userID, exists := GetUserIDFromSession(c)
56
3x
    if !exists {
57
        HandleAppError(c, contextutils.ErrUnauthorized)
58
        return
59
    }
60

61
    // Parse date parameter
62
3x
    dateStr := c.Param("date")
63
3x
    if dateStr == "" {
64
        HandleAppError(c, contextutils.ErrMissingRequired)
65
        return
66
    }
67

68
    // Parse date in user's timezone
69
3x
    date, timezone, err := h.ParseDateInUserTimezone(ctx, userID, dateStr)
70
3x
    if err != nil {
71
        if strings.Contains(err.Error(), "invalid date format") {
72
            HandleAppError(c, contextutils.ErrInvalidFormat)
73
            return
74
        }
75
        HandleAppError(c, contextutils.WrapError(err, "failed to get user information"))
76
        return
77
    }
78

79
3x
    span.SetAttributes(
80
3x
        observability.AttributeUserID(userID),
81
3x
        attribute.String("date", dateStr),
82
3x
        attribute.String("timezone", timezone),
83
3x
    )
84
3x

85
3x
    // Get word of the day
86
3x
    word, err := h.wordOfTheDayService.GetWordOfTheDay(ctx, userID, date)
87
3x
    if err != nil {
88
3x
        h.logger.Error(ctx, "Failed to get word of the day", err, map[string]interface{}{
89
3x
            "user_id": userID,
90
3x
            "date":    dateStr,
91
3x
        })
92
3x
        HandleAppError(c, contextutils.WrapError(err, "failed to get word of the day"))
93
3x
        return
94
3x
    }
95

96
    c.JSON(http.StatusOK, word)
97
}
98

99
// GetWordOfTheDayToday handles GET /v1/word-of-day
100
// It resolves "today" in the user's timezone and returns that day's word
101
2x
func (h *WordOfTheDayHandler) GetWordOfTheDayToday(c *gin.Context) {
102
2x
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "get_word_of_the_day_today")
103
2x
    defer observability.FinishSpan(span, nil)
104
2x

105
2x
    userID, exists := GetUserIDFromSession(c)
106
2x
    if !exists {
107
        HandleAppError(c, contextutils.ErrUnauthorized)
108
        return
109
    }
110

111
    // Determine today's date string and parse it in user's timezone
112
2x
    todayStr := time.Now().Format("2006-01-02")
113
2x
    date, timezone, err := h.ParseDateInUserTimezone(ctx, userID, todayStr)
114
2x
    if err != nil {
115
        HandleAppError(c, contextutils.WrapError(err, "failed to resolve today's date"))
116
        return
117
    }
118

119
2x
    span.SetAttributes(
120
2x
        observability.AttributeUserID(userID),
121
2x
        attribute.String("date", todayStr),
122
2x
        attribute.String("timezone", timezone),
123
2x
    )
124
2x

125
2x
    // Get word of the day
126
2x
    word, err := h.wordOfTheDayService.GetWordOfTheDay(ctx, userID, date)
127
2x
    if err != nil {
128
2x
        h.logger.Error(ctx, "Failed to get today's word of the day", err, map[string]interface{}{
129
2x
            "user_id": userID,
130
2x
            "date":    todayStr,
131
2x
        })
132
2x
        HandleAppError(c, contextutils.WrapError(err, "failed to get word of the day"))
133
2x
        return
134
2x
    }
135

136
    c.JSON(http.StatusOK, word)
137
}
138

139
// GetWordOfTheDayEmbed handles GET /v1/word-of-day/:date/embed
140
// This endpoint returns HTML for embedding in an iframe. Requires an authenticated session.
141
2x
func (h *WordOfTheDayHandler) GetWordOfTheDayEmbed(c *gin.Context) {
142
2x
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "get_word_of_the_day_embed")
143
2x
    defer observability.FinishSpan(span, nil)
144
2x

145
2x
    // Determine user via session; no query parameters are supported
146
2x
    userID, exists := GetUserIDFromSession(c)
147
2x
    if !exists {
148
        c.Data(http.StatusUnauthorized, "text/html; charset=utf-8", []byte("Unauthorized"))
149
        return
150
    }
151

152
    // Resolve date parameter from path, query, or default to today's date
153
2x
    dateStr := c.Param("date")
154
2x
    if dateStr == "" {
155
1x
        dateStr = c.Query("date")
156
1x
    }
157
2x
    if dateStr == "" {
158
1x
        dateStr = time.Now().Format("2006-01-02")
159
1x
    }
160

161
    // Parse date in user's timezone
162
2x
    date, timezone, err := h.ParseDateInUserTimezone(ctx, userID, dateStr)
163
2x
    if err != nil {
164
        c.Data(http.StatusBadRequest, "text/html; charset=utf-8", []byte("Invalid date format"))
165
        return
166
    }
167

168
2x
    span.SetAttributes(
169
2x
        observability.AttributeUserID(userID),
170
2x
        attribute.String("date", dateStr),
171
2x
        attribute.String("timezone", timezone),
172
2x
    )
173
2x

174
2x
    // Get word of the day
175
2x
    word, err := h.wordOfTheDayService.GetWordOfTheDay(ctx, userID, date)
176
2x
    if err != nil {
177
2x
        h.logger.Error(ctx, "Failed to get word of the day for embed", err, map[string]interface{}{
178
2x
            "user_id": userID,
179
2x
            "date":    dateStr,
180
2x
        })
181
2x
        c.Data(http.StatusInternalServerError, "text/html; charset=utf-8", []byte("Failed to load word of the day"))
182
2x
        return
183
2x
    }
184

185
    // Render HTML template
186
    html := h.renderEmbedHTML(word)
187
    c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(html))
188
}
189

190
// GetWordOfTheDayHistory handles GET /v1/word-of-day/history
191
1x
func (h *WordOfTheDayHandler) GetWordOfTheDayHistory(c *gin.Context) {
192
1x
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "get_word_of_the_day_history")
193
1x
    defer observability.FinishSpan(span, nil)
194
1x

195
1x
    userID, exists := GetUserIDFromSession(c)
196
1x
    if !exists {
197
        HandleAppError(c, contextutils.ErrUnauthorized)
198
        return
199
    }
200

201
    // Parse date range parameters
202
1x
    startDateStr := c.Query("start_date")
203
1x
    endDateStr := c.Query("end_date")
204
1x

205
1x
    if startDateStr == "" || endDateStr == "" {
206
        HandleAppError(c, contextutils.ErrMissingRequired)
207
        return
208
    }
209

210
    // Parse dates in user's timezone
211
1x
    startDate, _, err := h.ParseDateInUserTimezone(ctx, userID, startDateStr)
212
1x
    if err != nil {
213
        HandleAppError(c, contextutils.WrapError(err, "invalid start_date"))
214
        return
215
    }
216

217
1x
    endDate, _, err := h.ParseDateInUserTimezone(ctx, userID, endDateStr)
218
1x
    if err != nil {
219
        HandleAppError(c, contextutils.WrapError(err, "invalid end_date"))
220
        return
221
    }
222

223
1x
    span.SetAttributes(
224
1x
        observability.AttributeUserID(userID),
225
1x
        attribute.String("start_date", startDateStr),
226
1x
        attribute.String("end_date", endDateStr),
227
1x
    )
228
1x

229
1x
    // Get word history
230
1x
    words, err := h.wordOfTheDayService.GetWordHistory(ctx, userID, startDate, endDate)
231
1x
    if err != nil {
232
        h.logger.Error(ctx, "Failed to get word of the day history", err, map[string]interface{}{
233
            "user_id":    userID,
234
            "start_date": startDateStr,
235
            "end_date":   endDateStr,
236
        })
237
        HandleAppError(c, contextutils.WrapError(err, "failed to get word history"))
238
        return
239
    }
240

241
1x
    c.JSON(http.StatusOK, gin.H{
242
1x
        "words": words,
243
1x
        "count": len(words),
244
1x
    })
245
}
246

247
// renderEmbedHTML renders the embed HTML template
248
func (h *WordOfTheDayHandler) renderEmbedHTML(word *models.WordOfTheDayDisplay) string {
249
    if word == nil {
250
        // Gracefully handle missing word to avoid panics in tests/environments with no data
251
        return "<html><head><meta charset=\"UTF-8\"></head><body>Word of the Day is unavailable.</body></html>"
252
    }
253
    const embedTemplate = `
254
<!DOCTYPE html>
255
<html lang="en">
256
<head>
257
    <meta charset="UTF-8">
258
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
259
    <title>Word of the Day</title>
260
    <style>
261
        * {
262
            margin: 0;
263
            padding: 0;
264
            box-sizing: border-box;
265
        }
266
        body {
267
            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
268
            background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
269
            color: #333;
270
            padding: 20px;
271
            min-height: 100vh;
272
            display: flex;
273
            align-items: center;
274
            justify-content: center;
275
        }
276
        .card {
277
            background: white;
278
            border-radius: 16px;
279
            box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
280
            padding: 30px;
281
            max-width: 500px;
282
            width: 100%;
283
        }
284
        .date {
285
            color: #667eea;
286
            font-size: 14px;
287
            font-weight: 600;
288
            text-transform: uppercase;
289
            letter-spacing: 1px;
290
            margin-bottom: 10px;
291
        }
292
        .word {
293
            font-size: 48px;
294
            font-weight: bold;
295
            color: #1a1a1a;
296
            margin-bottom: 10px;
297
            line-height: 1.2;
298
        }
299
        .translation {
300
            font-size: 24px;
301
            color: #667eea;
302
            margin-bottom: 20px;
303
            font-style: italic;
304
        }
305
        .sentence {
306
            font-size: 18px;
307
            line-height: 1.6;
308
            color: #555;
309
            background: #f7f7f7;
310
            padding: 20px;
311
            border-radius: 8px;
312
            border-left: 4px solid #667eea;
313
            margin-bottom: 15px;
314
        }
315
        .meta {
316
            display: flex;
317
            gap: 10px;
318
            flex-wrap: wrap;
319
            margin-top: 20px;
320
        }
321
        .badge {
322
            background: #e0e7ff;
323
            color: #667eea;
324
            padding: 6px 12px;
325
            border-radius: 20px;
326
            font-size: 12px;
327
            font-weight: 600;
328
        }
329
        .explanation {
330
            font-size: 14px;
331
            color: #666;
332
            margin-top: 15px;
333
            padding: 15px;
334
            background: #fafafa;
335
            border-radius: 8px;
336
            border-left: 3px solid #764ba2;
337
        }
338
    </style>
339
</head>
340
<body>
341
    <div class="card">
342
        <div class="date">{{.FormattedDate}}</div>
343
        <div class="word">{{.Word}}</div>
344
        <div class="translation">{{.Translation}}</div>
345
        {{if .Sentence}}
346
        <div class="sentence">{{.Sentence}}</div>
347
        {{end}}
348
        <div class="meta">
349
            {{if .Language}}<span class="badge">{{.Language}}</span>{{end}}
350
            {{if .Level}}<span class="badge">{{.Level}}</span>{{end}}
351
            {{if .TopicCategory}}<span class="badge">{{.TopicCategory}}</span>{{end}}
352
        </div>
353
        {{if .Explanation}}
354
        <div class="explanation">{{.Explanation}}</div>
355
        {{end}}
356
    </div>
357
</body>
358
</html>
359
`
360

361
    tmpl, err := template.New("embed").Parse(embedTemplate)
362
    if err != nil {
363
        return fmt.Sprintf("<html><body>Error rendering template: %v</body></html>", err)
364
    }
365

366
    data := struct {
367
        FormattedDate string
368
        Word          string
369
        Translation   string
370
        Sentence      string
371
        Language      string
372
        Level         string
373
        TopicCategory string
374
        Explanation   string
375
    }{
376
        FormattedDate: word.Date.Format("January 2, 2006"),
377
        Word:          word.Word,
378
        Translation:   word.Translation,
379
        Sentence:      word.Sentence,
380
        Language:      word.Language,
381
        Level:         word.Level,
382
        TopicCategory: word.TopicCategory,
383
        Explanation:   word.Explanation,
384
    }
385

386
    var buf strings.Builder
387
    if err := tmpl.Execute(&buf, data); err != nil {
388
        return fmt.Sprintf("<html><body>Error executing template: %v</body></html>", err)
389
    }
390

391
    return buf.String()
392
}
393


			
quizapp internal handlers worker_admin_handler.go
43.6%
Statements
164/376
1
package handlers
2

3
import (
4
    "errors"
5
    "fmt"
6
    "html/template"
7
    "net/http"
8
    "strconv"
9
    "strings"
10
    "time"
11

12
    "quizapp/internal/config"
13
    "quizapp/internal/observability"
14
    "quizapp/internal/services"
15
    contextutils "quizapp/internal/utils"
16
    "quizapp/internal/worker"
17

18
    "github.com/gin-gonic/gin"
19
    "go.opentelemetry.io/otel/attribute"
20
)
21

22
// WorkerAdminHandler handles worker administration endpoints
23
type WorkerAdminHandler struct {
24
    userService          services.UserServiceInterface
25
    questionService      services.QuestionServiceInterface
26
    aiService            services.AIServiceInterface
27
    config               *config.Config
28
    worker               *worker.Worker
29
    workerService        services.WorkerServiceInterface
30
    templates            *template.Template
31
    learningService      services.LearningServiceInterface
32
    dailyQuestionService services.DailyQuestionServiceInterface
33
    logger               *observability.Logger
34
}
35

36
// NewWorkerAdminHandlerWithLogger creates a new WorkerAdminHandler
37
func NewWorkerAdminHandlerWithLogger(
38
    userService services.UserServiceInterface,
39
    questionService services.QuestionServiceInterface,
40
    aiService services.AIServiceInterface,
41
    cfg *config.Config,
42
    worker *worker.Worker,
43
    workerService services.WorkerServiceInterface,
44
    learningService services.LearningServiceInterface,
45
    dailyQuestionService services.DailyQuestionServiceInterface,
46
    logger *observability.Logger,
47
11x
) *WorkerAdminHandler {
48
11x
    return &WorkerAdminHandler{
49
11x
        userService:          userService,
50
11x
        questionService:      questionService,
51
11x
        aiService:            aiService,
52
11x
        config:               cfg,
53
11x
        worker:               worker,
54
11x
        workerService:        workerService,
55
11x
        templates:            nil,
56
11x
        learningService:      learningService,
57
11x
        dailyQuestionService: dailyQuestionService,
58
11x
        logger:               logger,
59
11x
    }
60
11x
}
61

62
// GetWorkerDetails returns detailed worker information
63
3x
func (h *WorkerAdminHandler) GetWorkerDetails(c *gin.Context) {
64
3x
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "get_worker_details")
65
3x
    defer span.End()
66
3x
    // Get worker status from local instance if available
67
3x
    var localStatus worker.Status
68
3x
    var localHistory []worker.RunRecord
69
3x
    if h.worker != nil {
70
3x
        localStatus = h.worker.GetStatus()
71
3x
        localHistory = h.worker.GetHistory()
72
3x
    }
73

74
    // Get global pause status
75
3x
    globalPaused, err := h.workerService.IsGlobalPaused(ctx)
76
3x
    if err != nil {
77
        // Log the error but continue with default value
78
        h.logger.Warn(ctx, "Failed to get global pause status", map[string]interface{}{"error": err.Error()})
79
        globalPaused = false
80
    }
81

82
3x
    response := gin.H{
83
3x
        "status":        localStatus,
84
3x
        "history":       localHistory,
85
3x
        "global_paused": globalPaused,
86
3x
    }
87
3x

88
3x
    c.JSON(http.StatusOK, response)
89
}
90

91
// GetActivityLogs returns recent activity logs from the worker
92
1x
func (h *WorkerAdminHandler) GetActivityLogs(c *gin.Context) {
93
1x
    _, span := observability.TraceHandlerFunction(c.Request.Context(), "get_activity_logs")
94
1x
    defer span.End()
95
1x
    if h.worker == nil {
96
        HandleAppError(c, contextutils.ErrServiceUnavailable)
97
        return
98
    }
99

100
1x
    logs := h.worker.GetActivityLogs()
101
1x
    c.JSON(http.StatusOK, gin.H{"logs": logs})
102
}
103

104
// PauseWorker pauses the worker globally
105
3x
func (h *WorkerAdminHandler) PauseWorker(c *gin.Context) {
106
3x
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "pause_worker")
107
3x
    defer span.End()
108
3x
    if err := h.workerService.SetGlobalPause(ctx, true); err != nil {
109
        HandleAppError(c, contextutils.WrapError(err, "failed to pause worker globally"))
110
        return
111
    }
112

113
    // Also pause the local worker instance if available
114
3x
    if h.worker != nil {
115
2x
        h.worker.Pause(ctx)
116
2x
    }
117

118
3x
    c.JSON(http.StatusOK, gin.H{"message": "Worker paused globally"})
119
}
120

121
// ResumeWorker resumes the worker globally
122
3x
func (h *WorkerAdminHandler) ResumeWorker(c *gin.Context) {
123
3x
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "resume_worker")
124
3x
    defer span.End()
125
3x
    if err := h.workerService.SetGlobalPause(ctx, false); err != nil {
126
        HandleAppError(c, contextutils.WrapError(err, "failed to resume worker globally"))
127
        return
128
    }
129

130
    // Also resume the local worker instance if available
131
3x
    if h.worker != nil {
132
2x
        h.worker.Resume(ctx)
133
2x
    }
134

135
3x
    c.JSON(http.StatusOK, gin.H{"message": "Worker resumed globally"})
136
}
137

138
// GetWorkerStatus returns current worker status
139
6x
func (h *WorkerAdminHandler) GetWorkerStatus(c *gin.Context) {
140
6x
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "get_worker_status")
141
6x
    defer span.End()
142
6x
    instance := c.DefaultQuery("instance", "default")
143
6x

144
6x
    status, err := h.workerService.GetWorkerStatus(ctx, instance)
145
6x
    if err != nil {
146
        HandleAppError(c, contextutils.WrapError(err, "failed to get worker status"))
147
        return
148
    }
149

150
6x
    c.JSON(http.StatusOK, status)
151
}
152

153
// TriggerWorkerRun triggers a manual worker run
154
2x
func (h *WorkerAdminHandler) TriggerWorkerRun(c *gin.Context) {
155
2x
    _, span := observability.TraceHandlerFunction(c.Request.Context(), "trigger_worker_run")
156
2x
    defer span.End()
157
2x
    if h.worker != nil {
158
2x
        h.worker.TriggerManualRun()
159
2x
        c.JSON(http.StatusOK, gin.H{"message": "Worker run triggered"})
160
2x
    } else {
161
        HandleAppError(c, contextutils.ErrServiceUnavailable)
162
    }
163
}
164

165
// PauseWorkerUser pauses question generation for a specific user
166
4x
func (h *WorkerAdminHandler) PauseWorkerUser(c *gin.Context) {
167
4x
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "pause_user")
168
4x
    defer span.End()
169
4x
    var req struct {
170
4x
        UserID int `json:"user_id" binding:"required"`
171
4x
    }
172
4x

173
4x
    if err := c.ShouldBindJSON(&req); err != nil {
174
1x
        HandleAppError(c, contextutils.NewAppErrorWithCause(
175
1x
            contextutils.ErrorCodeInvalidInput,
176
1x
            contextutils.SeverityWarn,
177
1x
            "Invalid request",
178
1x
            "",
179
1x
            err,
180
1x
        ))
181
1x
        return
182
1x
    }
183

184
3x
    if err := h.workerService.SetUserPause(ctx, req.UserID, true); err != nil {
185
        HandleAppError(c, contextutils.WrapError(err, "failed to pause user"))
186
        return
187
    }
188

189
3x
    c.JSON(http.StatusOK, gin.H{"message": "User paused successfully"})
190
}
191

192
// ResumeWorkerUser resumes question generation for a specific user
193
3x
func (h *WorkerAdminHandler) ResumeWorkerUser(c *gin.Context) {
194
3x
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "resume_user")
195
3x
    defer span.End()
196
3x
    var req struct {
197
3x
        UserID int `json:"user_id" binding:"required"`
198
3x
    }
199
3x

200
3x
    if err := c.ShouldBindJSON(&req); err != nil {
201
1x
        HandleAppError(c, contextutils.NewAppErrorWithCause(
202
1x
            contextutils.ErrorCodeInvalidInput,
203
1x
            contextutils.SeverityWarn,
204
1x
            "Invalid request",
205
1x
            "",
206
1x
            err,
207
1x
        ))
208
1x
        return
209
1x
    }
210

211
2x
    if err := h.workerService.SetUserPause(ctx, req.UserID, false); err != nil {
212
        HandleAppError(c, contextutils.WrapError(err, "failed to resume user"))
213
        return
214
    }
215

216
2x
    c.JSON(http.StatusOK, gin.H{"message": "User resumed successfully"})
217
}
218

219
// GetWorkerUsers returns basic user list for worker controls
220
1x
func (h *WorkerAdminHandler) GetWorkerUsers(c *gin.Context) {
221
1x
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "get_worker_users")
222
1x
    defer span.End()
223
1x
    users, err := h.userService.GetAllUsers(ctx)
224
1x
    if err != nil {
225
        HandleAppError(c, contextutils.WrapError(err, "failed to get users"))
226
        return
227
    }
228

229
    // Add pause status for each user
230
1x
    var userList []gin.H
231
1x
    for _, user := range users {
232
1x
        isPaused, _ := h.workerService.IsUserPaused(ctx, user.ID)
233
1x
        userList = append(userList, gin.H{
234
1x
            "id":        user.ID,
235
1x
            "username":  user.Username,
236
1x
            "is_paused": isPaused,
237
1x
        })
238
1x
    }
239

240
1x
    c.JSON(http.StatusOK, gin.H{"users": userList})
241
}
242

243
// GetSystemHealth returns comprehensive system health
244
2x
func (h *WorkerAdminHandler) GetSystemHealth(c *gin.Context) {
245
2x
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "get_system_health")
246
2x
    defer span.End()
247
2x
    health, err := h.workerService.GetWorkerHealth(ctx)
248
2x
    if err != nil {
249
        HandleAppError(c, contextutils.WrapError(err, "failed to get system health"))
250
        return
251
    }
252

253
2x
    c.JSON(http.StatusOK, health)
254
}
255

256
// GetAIConcurrencyStats returns AI service concurrency metrics from the worker
257
1x
func (h *WorkerAdminHandler) GetAIConcurrencyStats(c *gin.Context) {
258
1x
    _, span := observability.TraceHandlerFunction(c.Request.Context(), "get_ai_concurrency_stats")
259
1x
    defer span.End()
260
1x
    if h.aiService == nil {
261
        HandleAppError(c, contextutils.ErrAIProviderUnavailable)
262
        return
263
    }
264

265
1x
    stats := h.aiService.GetConcurrencyStats()
266
1x
    c.JSON(http.StatusOK, gin.H{
267
1x
        "ai_concurrency": stats,
268
1x
    })
269
}
270

271
// GetPriorityAnalytics returns priority system analytics
272
6x
func (h *WorkerAdminHandler) GetPriorityAnalytics(c *gin.Context) {
273
6x
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "get_priority_analytics")
274
6x
    defer span.End()
275
6x
    // Get priority score distribution
276
6x
    distribution, err := h.learningService.GetPriorityScoreDistribution(ctx)
277
6x
    if err != nil {
278
1x
        h.logger.Error(ctx, "Error getting priority score distribution", err, map[string]interface{}{})
279
1x
        distribution = map[string]interface{}{
280
1x
            "high":    0,
281
1x
            "medium":  0,
282
1x
            "low":     0,
283
1x
            "average": 0.0,
284
1x
        }
285
1x
    }
286

287
    // Get high priority questions
288
6x
    highPriorityQuestions, err := h.learningService.GetHighPriorityQuestions(ctx, 5)
289
6x
    if err != nil {
290
        h.logger.Error(ctx, "Error getting high priority questions", err, map[string]interface{}{})
291
        highPriorityQuestions = []map[string]interface{}{}
292
    }
293

294
6x
    response := gin.H{
295
6x
        "distribution":          distribution,
296
6x
        "highPriorityQuestions": highPriorityQuestions,
297
6x
    }
298
6x

299
6x
    c.JSON(http.StatusOK, response)
300
}
301

302
// GetUserPriorityAnalytics returns priority analytics for a specific user
303
3x
func (h *WorkerAdminHandler) GetUserPriorityAnalytics(c *gin.Context) {
304
3x
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "get_user_priority_analytics")
305
3x
    defer span.End()
306
3x
    userIDStr := c.Param("userID")
307
3x
    userID, err := strconv.Atoi(userIDStr)
308
3x
    if err != nil {
309
1x
        HandleAppError(c, contextutils.ErrInvalidFormat)
310
1x
        return
311
1x
    }
312

313
    // Verify user exists
314
2x
    user, err := h.userService.GetUserByID(ctx, userID)
315
2x
    if err != nil || user == nil {
316
1x
        HandleAppError(c, contextutils.ErrRecordNotFound)
317
1x
        return
318
1x
    }
319

320
    // Get user-specific priority score distribution
321
1x
    distribution, err := h.learningService.GetUserPriorityScoreDistribution(ctx, userID)
322
1x
    if err != nil {
323
        h.logger.Error(ctx, "Error getting user priority score distribution", err, map[string]interface{}{})
324
        distribution = map[string]interface{}{
325
            "high":    0,
326
            "medium":  0,
327
            "low":     0,
328
            "average": 0.0,
329
        }
330
    }
331

332
    // Get user's high priority questions
333
1x
    highPriorityQuestions, err := h.learningService.GetUserHighPriorityQuestions(ctx, userID, 10)
334
1x
    if err != nil {
335
        h.logger.Error(ctx, "Error getting user high priority questions", err, map[string]interface{}{})
336
        highPriorityQuestions = []map[string]interface{}{}
337
    }
338

339
    // Get user's weak areas
340
1x
    weakAreas, err := h.learningService.GetUserWeakAreas(ctx, userID, 5)
341
1x
    if err != nil {
342
        h.logger.Error(ctx, "Error getting user weak areas", err, map[string]interface{}{})
343
        weakAreas = []map[string]interface{}{}
344
    }
345

346
    // Get user's learning preferences
347
1x
    preferences, err := h.learningService.GetUserLearningPreferences(ctx, userID)
348
1x
    if err != nil {
349
        h.logger.Error(ctx, "Error getting user learning preferences", err, map[string]interface{}{})
350
        preferences = nil
351
    }
352

353
1x
    response := gin.H{
354
1x
        "user": gin.H{
355
1x
            "id":       user.ID,
356
1x
            "username": user.Username,
357
1x
        },
358
1x
        "distribution":          distribution,
359
1x
        "highPriorityQuestions": highPriorityQuestions,
360
1x
        "weakAreas":             weakAreas,
361
1x
        "learningPreferences":   preferences,
362
1x
    }
363
1x

364
1x
    c.JSON(http.StatusOK, response)
365
}
366

367
// GetUserPerformanceAnalytics returns user performance analytics
368
4x
func (h *WorkerAdminHandler) GetUserPerformanceAnalytics(c *gin.Context) {
369
4x
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "get_user_performance_analytics")
370
4x
    defer span.End()
371
4x
    // Get weak areas by topic
372
4x
    weakAreas, err := h.learningService.GetWeakAreasByTopic(ctx, 5)
373
4x
    if err != nil {
374
        h.logger.Error(ctx, "Error getting weak areas", err, map[string]interface{}{})
375
        weakAreas = []map[string]interface{}{}
376
    }
377

378
    // Get learning preferences usage
379
4x
    learningPreferences, err := h.learningService.GetLearningPreferencesUsage(ctx)
380
4x
    if err != nil {
381
        h.logger.Error(ctx, "Error getting learning preferences usage", err, map[string]interface{}{})
382
        learningPreferences = map[string]interface{}{}
383
    }
384

385
4x
    response := gin.H{
386
4x
        "weakAreas":           weakAreas,
387
4x
        "learningPreferences": learningPreferences,
388
4x
    }
389
4x

390
4x
    c.JSON(http.StatusOK, response)
391
}
392

393
// GetGenerationIntelligence returns question generation intelligence
394
4x
func (h *WorkerAdminHandler) GetGenerationIntelligence(c *gin.Context) {
395
4x
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "get_generation_intelligence")
396
4x
    defer span.End()
397
4x
    // Get gap analysis
398
4x
    gapAnalysis, err := h.learningService.GetQuestionTypeGaps(ctx)
399
4x
    if err != nil {
400
        h.logger.Error(ctx, "Error getting gap analysis", err, map[string]interface{}{})
401
        gapAnalysis = []map[string]interface{}{}
402
    }
403

404
    // Get generation suggestions
405
4x
    generationSuggestions, err := h.learningService.GetGenerationSuggestions(ctx)
406
4x
    if err != nil {
407
        h.logger.Error(ctx, "Error getting generation suggestions", err, map[string]interface{}{})
408
        generationSuggestions = []map[string]interface{}{}
409
    }
410

411
    // Ensure we always return arrays, not nil
412
4x
    if gapAnalysis == nil {
413
2x
        gapAnalysis = []map[string]interface{}{}
414
2x
    }
415
4x
    if generationSuggestions == nil {
416
2x
        generationSuggestions = []map[string]interface{}{}
417
2x
    }
418

419
4x
    response := gin.H{
420
4x
        "gapAnalysis":           gapAnalysis,
421
4x
        "generationSuggestions": generationSuggestions,
422
4x
    }
423
4x

424
4x
    c.JSON(http.StatusOK, response)
425
}
426

427
// GetSystemHealthAnalytics returns system health analytics
428
3x
func (h *WorkerAdminHandler) GetSystemHealthAnalytics(c *gin.Context) {
429
3x
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "get_system_health_analytics")
430
3x
    defer span.End()
431
3x
    // Get performance metrics
432
3x
    performance, err := h.learningService.GetPrioritySystemPerformance(ctx)
433
3x
    if err != nil {
434
        h.logger.Error(ctx, "Error getting performance metrics", err, map[string]interface{}{})
435
        performance = map[string]interface{}{}
436
    }
437

438
    // Get background jobs status
439
3x
    backgroundJobs, err := h.learningService.GetBackgroundJobsStatus(ctx)
440
3x
    if err != nil {
441
        h.logger.Error(ctx, "Error getting background jobs status", err, map[string]interface{}{})
442
        backgroundJobs = map[string]interface{}{}
443
    }
444

445
3x
    response := gin.H{
446
3x
        "performance":    performance,
447
3x
        "backgroundJobs": backgroundJobs,
448
3x
    }
449
3x

450
3x
    c.JSON(http.StatusOK, response)
451
}
452

453
// GetUserComparisonAnalytics returns comparison analytics between users
454
6x
func (h *WorkerAdminHandler) GetUserComparisonAnalytics(c *gin.Context) {
455
6x
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "get_user_comparison_analytics")
456
6x
    defer span.End()
457
6x
    userIDsParam := c.Query("user_ids")
458
6x
    if userIDsParam == "" {
459
1x
        HandleAppError(c, contextutils.ErrMissingRequired)
460
1x
        return
461
1x
    }
462

463
    // Split comma-separated user IDs
464
5x
    userIDsStr := strings.Split(userIDsParam, ",")
465
5x
    if len(userIDsStr) == 0 {
466
        HandleAppError(c, contextutils.ErrMissingRequired)
467
        return
468
    }
469

470
5x
    var userIDs []int
471
5x
    for _, idStr := range userIDsStr {
472
6x
        idStr = strings.TrimSpace(idStr) // Remove whitespace
473
6x
        if idStr == "" {
474
            continue
475
        }
476
6x
        id, err := strconv.Atoi(idStr)
477
6x
        if err != nil {
478
2x
            HandleAppError(c, contextutils.NewAppErrorWithCause(
479
2x
                contextutils.ErrorCodeInvalidFormat,
480
2x
                contextutils.SeverityWarn,
481
2x
                "Invalid user ID",
482
2x
                idStr,
483
2x
                err,
484
2x
            ))
485
2x
            return
486
2x
        }
487
4x
        userIDs = append(userIDs, id)
488
    }
489

490
3x
    if len(userIDs) == 0 {
491
        HandleAppError(c, contextutils.ErrMissingRequired)
492
        return
493
    }
494

495
    // Get comparison data for each user
496
3x
    var comparisonData []gin.H
497
3x
    for _, userID := range userIDs {
498
4x
        user, err := h.userService.GetUserByID(ctx, userID)
499
4x
        if err != nil {
500
            continue // Skip invalid users
501
        }
502

503
4x
        distribution, _ := h.learningService.GetUserPriorityScoreDistribution(ctx, userID)
504
4x
        weakAreas, _ := h.learningService.GetUserWeakAreas(ctx, userID, 3)
505
4x

506
4x
        userData := gin.H{
507
4x
            "user": gin.H{
508
4x
                "id":       user.ID,
509
4x
                "username": user.Username,
510
4x
            },
511
4x
            "distribution": distribution,
512
4x
            "weakAreas":    weakAreas,
513
4x
        }
514
4x
        comparisonData = append(comparisonData, userData)
515
    }
516

517
3x
    c.JSON(http.StatusOK, gin.H{"comparison": comparisonData})
518
}
519

520
// GetConfigz returns the merged config as pretty-printed JSON
521
1x
func (h *WorkerAdminHandler) GetConfigz(c *gin.Context) {
522
1x
    _, span := observability.TraceHandlerFunction(c.Request.Context(), "get_configz")
523
1x
    defer span.End()
524
1x
    c.IndentedJSON(http.StatusOK, h.config)
525
1x
}
526

527
// GetNotificationStats returns comprehensive notification statistics
528
func (h *WorkerAdminHandler) GetNotificationStats(c *gin.Context) {
529
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "get_notification_stats")
530
    defer span.End()
531

532
    // Get notification statistics from database
533
    stats, err := h.workerService.GetNotificationStats(ctx)
534
    if err != nil {
535
        h.logger.Error(ctx, "Failed to get notification stats", err, nil)
536
        c.JSON(http.StatusInternalServerError, gin.H{
537
            "error":   "Failed to get notification statistics",
538
            "details": err.Error(),
539
        })
540
        return
541
    }
542

543
    c.JSON(http.StatusOK, stats)
544
}
545

546
// GetNotificationErrors returns paginated notification errors
547
func (h *WorkerAdminHandler) GetNotificationErrors(c *gin.Context) {
548
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "get_notification_errors")
549
    defer span.End()
550

551
    // Parse pagination and filters
552
    page, pageSize := ParsePagination(c, 1, 20, 100)
553
    f := ParseFilters(c, "error_type", "notification_type", "resolved")
554
    errorType := f["error_type"]
555
    notificationType := f["notification_type"]
556
    resolved := f["resolved"]
557

558
    // Get notification errors from database
559
    errors, pagination, stats, err := h.workerService.GetNotificationErrors(ctx, page, pageSize, errorType, notificationType, resolved)
560
    if err != nil {
561
        h.logger.Error(ctx, "Failed to get notification errors", err, nil)
562
        c.JSON(http.StatusInternalServerError, gin.H{
563
            "error":   "Failed to get notification errors",
564
            "details": err.Error(),
565
        })
566
        return
567
    }
568

569
    WritePaginated(c, "errors", errors, pagination, gin.H{"stats": stats})
570
}
571

572
// GetSentNotifications returns paginated sent notifications
573
func (h *WorkerAdminHandler) GetSentNotifications(c *gin.Context) {
574
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "get_sent_notifications")
575
    defer span.End()
576

577
    // Parse pagination and filters
578
    page, pageSize := ParsePagination(c, 1, 20, 100)
579
    f := ParseFilters(c, "notification_type", "status", "sent_after", "sent_before")
580
    notificationType := f["notification_type"]
581
    status := f["status"]
582
    sentAfter := f["sent_after"]
583
    sentBefore := f["sent_before"]
584

585
    // Get sent notifications from database
586
    notifications, pagination, stats, err := h.workerService.GetSentNotifications(ctx, page, pageSize, notificationType, status, sentAfter, sentBefore)
587
    if err != nil {
588
        h.logger.Error(ctx, "Failed to get sent notifications", err, nil)
589
        c.JSON(http.StatusInternalServerError, gin.H{
590
            "error":   "Failed to get sent notifications",
591
            "details": err.Error(),
592
        })
593
        return
594
    }
595

596
    WritePaginated(c, "notifications", notifications, pagination, gin.H{"stats": stats})
597
}
598

599
// CreateTestSentNotification creates a test sent notification for testing
600
func (h *WorkerAdminHandler) CreateTestSentNotification(c *gin.Context) {
601
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "create_test_sent_notification")
602
    defer span.End()
603

604
    // Parse request body
605
    var request struct {
606
        UserID           int    `json:"user_id" binding:"required"`
607
        NotificationType string `json:"notification_type" binding:"required"`
608
        Subject          string `json:"subject" binding:"required"`
609
        TemplateName     string `json:"template_name" binding:"required"`
610
        Status           string `json:"status" binding:"required"`
611
        ErrorMessage     string `json:"error_message"`
612
    }
613

614
    if err := c.ShouldBindJSON(&request); err != nil {
615
        HandleAppError(c, contextutils.NewAppErrorWithCause(
616
            contextutils.ErrorCodeInvalidInput,
617
            contextutils.SeverityWarn,
618
            "Invalid request body",
619
            "",
620
            err,
621
        ))
622
        return
623
    }
624

625
    // Create test notification
626
    err := h.workerService.CreateTestSentNotification(ctx, request.UserID, request.NotificationType, request.Subject, request.TemplateName, request.Status, request.ErrorMessage)
627
    if err != nil {
628
        h.logger.Error(ctx, "Failed to create test sent notification", err, map[string]interface{}{
629
            "user_id":           request.UserID,
630
            "notification_type": request.NotificationType,
631
        })
632
        c.JSON(http.StatusInternalServerError, gin.H{
633
            "error":   "Failed to create test sent notification",
634
            "details": err.Error(),
635
        })
636
        return
637
    }
638

639
    c.JSON(http.StatusOK, gin.H{"message": "Test sent notification created successfully"})
640
}
641

642
// ForceSendNotification forces sending a notification to a user, bypassing normal checks
643
func (h *WorkerAdminHandler) ForceSendNotification(c *gin.Context) {
644
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "force_send_notification")
645
    defer span.End()
646

647
    // Parse request body
648
    var request struct {
649
        Username string `json:"username" binding:"required"`
650
    }
651

652
    if err := c.ShouldBindJSON(&request); err != nil {
653
        HandleAppError(c, contextutils.NewAppErrorWithCause(
654
            contextutils.ErrorCodeInvalidInput,
655
            contextutils.SeverityWarn,
656
            "Invalid request body",
657
            "",
658
            err,
659
        ))
660
        return
661
    }
662

663
    // Get user by username
664
    user, err := h.userService.GetUserByUsername(ctx, request.Username)
665
    if err != nil {
666
        h.logger.Error(ctx, "Failed to get user by username", err, map[string]interface{}{
667
            "username": request.Username,
668
        })
669
        c.JSON(http.StatusInternalServerError, gin.H{
670
            "error":   "Failed to get user",
671
            "details": err.Error(),
672
        })
673
        return
674
    }
675

676
    if user == nil {
677
        HandleAppError(c, contextutils.NewAppError(
678
            contextutils.ErrorCodeRecordNotFound,
679
            contextutils.SeverityInfo,
680
            fmt.Sprintf("User '%s' not found", request.Username),
681
            "",
682
        ))
683
        return
684
    }
685

686
    // Check if user has email address
687
    if !user.Email.Valid || user.Email.String == "" {
688
        HandleAppError(c, contextutils.ErrMissingRequired)
689
        return
690
    }
691

692
    // Get user's learning preferences to check daily reminder setting
693
    prefs, err := h.learningService.GetUserLearningPreferences(ctx, user.ID)
694
    if err != nil {
695
        h.logger.Error(ctx, "Failed to get user learning preferences", err, map[string]interface{}{
696
            "user_id": user.ID,
697
        })
698
        c.JSON(http.StatusInternalServerError, gin.H{
699
            "error":   "Failed to get user preferences",
700
            "details": err.Error(),
701
        })
702
        return
703
    }
704

705
    // Check if daily reminders are enabled for this user
706
    if prefs == nil || !prefs.DailyReminderEnabled {
707
        HandleAppError(c, contextutils.NewAppError(contextutils.ErrorCodeInvalidInput, contextutils.SeverityWarn, "User has daily reminders disabled", ""))
708
        return
709
    }
710

711
    // Force send the daily reminder (bypassing time and date checks)
712
    subject := "Time for your daily quiz! ð"
713
    status := "sent"
714
    errorMsg := ""
715

716
    // Get email service from worker
717
    emailService := h.worker.GetEmailService()
718
    if emailService == nil {
719
        HandleAppError(c, contextutils.ErrServiceUnavailable)
720
        return
721
    }
722

723
    // Send the email
724
    if err := emailService.SendDailyReminder(ctx, user); err != nil {
725
        h.logger.Error(ctx, "Failed to send forced daily reminder", err, map[string]interface{}{
726
            "user_id": user.ID,
727
            "email":   user.Email.String,
728
        })
729
        HandleAppError(c, contextutils.WrapError(err, "failed to send notification"))
730
        return
731
    }
732

733
    // Record the sent notification in the database
734
    if err := emailService.RecordSentNotification(ctx, user.ID, "daily_reminder", subject, "daily_reminder", status, errorMsg); err != nil {
735
        h.logger.Error(ctx, "Failed to record sent notification", err, map[string]interface{}{
736
            "user_id": user.ID,
737
        })
738
        // Don't fail the request if recording fails
739
    }
740

741
    // Update the last reminder sent timestamp for this user
742
    if err := h.learningService.UpdateLastDailyReminderSent(ctx, user.ID); err != nil {
743
        h.logger.Error(ctx, "Failed to update last daily reminder sent timestamp", err, map[string]interface{}{
744
            "user_id": user.ID,
745
        })
746
        // Don't fail the request if timestamp update fails
747
    }
748

749
    h.logger.Info(ctx, "Forced notification sent successfully", map[string]interface{}{
750
        "user_id":  user.ID,
751
        "username": user.Username,
752
        "email":    user.Email.String,
753
    })
754

755
    c.JSON(http.StatusOK, gin.H{
756
        "message": "Notification sent successfully",
757
        "user": gin.H{
758
            "id":       user.ID,
759
            "username": user.Username,
760
            "email":    user.Email.String,
761
        },
762
        "notification": gin.H{
763
            "type":    "daily_reminder",
764
            "subject": subject,
765
            "status":  status,
766
        },
767
    })
768
}
769

770
// GetUserDailyQuestions returns daily questions for a specific user and date (admin only)
771
func (h *WorkerAdminHandler) GetUserDailyQuestions(c *gin.Context) {
772
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "admin_get_user_daily_questions")
773
    defer span.End()
774

775
    // Parse user ID
776
    userIDStr := c.Param("userId")
777
    userID, err := strconv.Atoi(userIDStr)
778
    if err != nil {
779
        HandleAppError(c, contextutils.ErrInvalidFormat)
780
        return
781
    }
782

783
    // Check if user exists
784
    user, err := h.userService.GetUserByID(ctx, userID)
785
    if err != nil {
786
        h.logger.Error(ctx, "Failed to get user for daily questions", err, map[string]interface{}{"user_id": userID})
787
        HandleAppError(c, contextutils.WrapError(err, "failed to get user"))
788
        return
789
    }
790
    if user == nil {
791
        HandleAppError(c, contextutils.ErrRecordNotFound)
792
        return
793
    }
794

795
    // Parse date
796
    dateStr := c.Param("date")
797
    if dateStr == "" {
798
        HandleAppError(c, contextutils.ErrMissingRequired)
799
        return
800
    }
801

802
    date, err := time.Parse("2006-01-02", dateStr)
803
    if err != nil {
804
        HandleAppError(c, contextutils.ErrInvalidFormat)
805
        return
806
    }
807

808
    // Add span attributes for observability
809
    span.SetAttributes(
810
        observability.AttributeUserID(userID),
811
        attribute.String("date", dateStr),
812
    )
813

814
    // Get daily questions for the user and date
815
    questions, err := h.dailyQuestionService.GetDailyQuestions(ctx, userID, date)
816
    if err != nil {
817
        h.logger.Error(ctx, "Failed to get user daily questions", err, map[string]interface{}{
818
            "user_id": userID,
819
            "date":    dateStr,
820
        })
821
        c.JSON(http.StatusInternalServerError, gin.H{
822
            "error":   "Failed to get daily questions",
823
            "details": err.Error(),
824
        })
825
        return
826
    }
827

828
    // Convert to API format (similar to the daily question handler)
829
    apiQuestions := make([]gin.H, len(questions))
830
    for i, q := range questions {
831
        var completedAt *time.Time
832
        if q.CompletedAt.Valid {
833
            completedAt = &q.CompletedAt.Time
834
        }
835

836
        apiQuestions[i] = gin.H{
837
            "id":              q.ID,
838
            "user_id":         q.UserID,
839
            "question_id":     q.QuestionID,
840
            "assignment_date": q.AssignmentDate,
841
            "is_completed":    q.IsCompleted,
842
            "completed_at":    completedAt,
843
            "created_at":      q.CreatedAt,
844
            // Per-user stats for admin UI
845
            "user_shown_count":     q.DailyShownCount,
846
            "user_total_responses": q.UserTotalResponses,
847
            "user_correct_count":   q.UserCorrectCount,
848
            "user_incorrect_count": q.UserIncorrectCount,
849
            "question": gin.H{
850
                "id":                  q.Question.ID,
851
                "type":                q.Question.Type,
852
                "language":            q.Question.Language,
853
                "level":               q.Question.Level,
854
                "difficulty_score":    q.Question.DifficultyScore,
855
                "content":             q.Question.Content,
856
                "correct_answer":      q.Question.CorrectAnswer,
857
                "explanation":         q.Question.Explanation,
858
                "created_at":          q.Question.CreatedAt,
859
                "status":              q.Question.Status,
860
                "topic_category":      q.Question.TopicCategory,
861
                "grammar_focus":       q.Question.GrammarFocus,
862
                "vocabulary_domain":   q.Question.VocabularyDomain,
863
                "scenario":            q.Question.Scenario,
864
                "style_modifier":      q.Question.StyleModifier,
865
                "difficulty_modifier": q.Question.DifficultyModifier,
866
                "time_context":        q.Question.TimeContext,
867
            },
868
        }
869
    }
870

871
    c.JSON(http.StatusOK, gin.H{"questions": apiQuestions})
872
}
873

874
// RegenerateUserDailyQuestions clears and regenerates daily questions for a specific user and date (admin only)
875
func (h *WorkerAdminHandler) RegenerateUserDailyQuestions(c *gin.Context) {
876
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "admin_regenerate_user_daily_questions")
877
    defer span.End()
878

879
    // Parse user ID
880
    userIDStr := c.Param("userId")
881
    userID, err := strconv.Atoi(userIDStr)
882
    if err != nil {
883
        HandleAppError(c, contextutils.ErrInvalidFormat)
884
        return
885
    }
886

887
    // Check if user exists
888
    user, err := h.userService.GetUserByID(ctx, userID)
889
    if err != nil {
890
        h.logger.Error(ctx, "Failed to get user for daily questions regeneration", err, map[string]interface{}{"user_id": userID})
891
        HandleAppError(c, contextutils.WrapError(err, "failed to get user"))
892
        return
893
    }
894
    if user == nil {
895
        HandleAppError(c, contextutils.ErrRecordNotFound)
896
        return
897
    }
898

899
    // Parse date
900
    dateStr := c.Param("date")
901
    if dateStr == "" {
902
        HandleAppError(c, contextutils.ErrMissingRequired)
903
        return
904
    }
905

906
    date, err := time.Parse("2006-01-02", dateStr)
907
    if err != nil {
908
        HandleAppError(c, contextutils.ErrInvalidFormat)
909
        return
910
    }
911

912
    // Add span attributes for observability
913
    span.SetAttributes(
914
        observability.AttributeUserID(userID),
915
        attribute.String("date", dateStr),
916
    )
917

918
    // For regeneration, we need to manually clear existing assignments and create new ones
919
    // Since the daily question service doesn't expose a direct way to clear assignments,
920
    // we'll use the worker service which should have database access for this admin operation
921

922
    // Check if worker service is available
923
    if h.workerService == nil {
924
        HandleAppError(c, contextutils.ErrServiceUnavailable)
925
        return
926
    }
927

928
    // Use the new RegenerateDailyQuestions method which clears existing assignments and creates new ones
929
    err = h.dailyQuestionService.RegenerateDailyQuestions(ctx, userID, date)
930
    if err != nil {
931
        h.logger.Error(ctx, "Failed to regenerate daily questions", err, map[string]interface{}{
932
            "user_id": userID,
933
            "date":    dateStr,
934
        })
935

936
        // If there are no questions available for assignment, prefer the structured error from the service
937
        var nqErr *services.NoQuestionsAvailableError
938
        if errors.As(err, &nqErr) {
939
            c.JSON(http.StatusBadRequest, gin.H{
940
                "error":                    "Failed to regenerate daily questions",
941
                "details":                  err.Error(),
942
                "user":                     gin.H{"id": user.ID, "username": user.Username, "language": nqErr.Language, "level": nqErr.Level},
943
                "candidate_count":          nqErr.CandidateCount,
944
                "candidate_ids":            nqErr.CandidateIDs,
945
                "total_matching_questions": nqErr.TotalMatching,
946
            })
947
            return
948
        }
949

950
        c.JSON(http.StatusInternalServerError, gin.H{
951
            "error":   "Failed to regenerate daily questions",
952
            "details": err.Error(),
953
        })
954
        return
955
    }
956

957
    h.logger.Info(ctx, "Daily questions regenerated successfully", map[string]interface{}{
958
        "user_id": userID,
959
        "date":    dateStr,
960
    })
961

962
    c.JSON(http.StatusOK, gin.H{"success": true, "message": "Daily questions regenerated successfully. All existing assignments have been cleared and new questions assigned."})
963
}
964


			
quizapp internal middleware
47.9%
Statements
359/749
auth.go
73.2%
90/123
error_recovery.go
51.6%
48/93
schema_loader.go
52.7%
221/419
validation.go
0.0%
0/114
quizapp internal middleware validation.go
73.2%
Statements
90/123
1
// Package middleware provides authentication and authorization middleware for the Gin web framework.
2
package middleware
3

4
import (
5
    "context"
6
    "net/http"
7
    "strings"
8

9
    "quizapp/internal/models"
10

11
    "github.com/gin-contrib/sessions"
12
    "github.com/gin-gonic/gin"
13
)
14

15
// Session keys for storing user information
16
const (
17
    // UserIDKey is the key used to store user ID in session
18
    UserIDKey = "user_id"
19
    // UsernameKey is the key used to store username in session
20
    UsernameKey = "username"
21
    // AuthMethodKey is the key used to store authentication method
22
    AuthMethodKey = "auth_method"
23
    // APIKeyIDKey is the key used to store API key ID (for API key auth)
24
    APIKeyIDKey = "api_key_id"
25
)
26

27
// AuthMethod constants
28
const (
29
    AuthMethodSession = "session"
30
    AuthMethodAPIKey  = "api_key"
31
)
32

33
// AuthAPIKeyValidator is an interface for validating API keys
34
type AuthAPIKeyValidator interface {
35
    ValidateAPIKey(ctx context.Context, rawKey string) (*models.AuthAPIKey, error)
36
    UpdateLastUsed(ctx context.Context, keyID int) error
37
}
38

39
// AuthUserServiceGetter is an interface for getting user info
40
type AuthUserServiceGetter interface {
41
    GetUserByID(ctx context.Context, userID int) (*models.User, error)
42
}
43

44
// RequireAuth returns a middleware that requires authentication
45
// This version only supports session-based auth for backward compatibility
46
7x
func RequireAuth() gin.HandlerFunc {
47
7x
    return func(c *gin.Context) {
48
9x
        // Fall back to session authentication
49
9x
        session := sessions.Default(c)
50
9x
        userID := session.Get(UserIDKey)
51
9x

52
9x
        if userID == nil {
53
2x
            c.JSON(http.StatusUnauthorized, gin.H{
54
2x
                "error": "Authentication required",
55
2x
                "code":  "UNAUTHORIZED",
56
2x
            })
57
2x
            c.Abort()
58
2x
            return
59
2x
        }
60

61
        // Validate user_id is an integer
62
7x
        userIDInt, ok := userID.(int)
63
7x
        if !ok {
64
1x
            // Try to convert from float64 (JSON numbers are often stored as float64)
65
1x
            if userIDFloat, ok := userID.(float64); ok {
66
                userIDInt = int(userIDFloat)
67
            } else {
68
1x
                c.JSON(http.StatusUnauthorized, gin.H{
69
1x
                    "error": "Authentication required",
70
1x
                    "code":  "UNAUTHORIZED",
71
1x
                })
72
1x
                c.Abort()
73
1x
                return
74
1x
            }
75
        }
76

77
        // Validate username is a string and not empty
78
6x
        username := session.Get(UsernameKey)
79
6x
        if username == nil {
80
1x
            c.JSON(http.StatusUnauthorized, gin.H{
81
1x
                "error": "Authentication required",
82
1x
                "code":  "UNAUTHORIZED",
83
1x
            })
84
1x
            c.Abort()
85
1x
            return
86
1x
        }
87

88
5x
        usernameStr, ok := username.(string)
89
5x
        if !ok || usernameStr == "" {
90
            c.JSON(http.StatusUnauthorized, gin.H{
91
                "error": "Authentication required",
92
                "code":  "UNAUTHORIZED",
93
            })
94
            c.Abort()
95
            return
96
        }
97

98
        // Store user info in context for handlers to use
99
5x
        c.Set(UserIDKey, userIDInt)
100
5x
        c.Set(UsernameKey, usernameStr)
101
5x
        c.Set(AuthMethodKey, AuthMethodSession)
102
5x

103
5x
        c.Next()
104
    }
105
}
106

107
// RequireAuthWithAPIKey returns a middleware that requires authentication via API key or session
108
// It checks for API key authentication first, then falls back to session authentication
109
4x
func RequireAuthWithAPIKey(apiKeyService AuthAPIKeyValidator, userService AuthUserServiceGetter) gin.HandlerFunc {
110
4x
    return func(c *gin.Context) {
111
4x
        // Check for API key authentication first
112
4x
        var rawKey string
113
4x
        authHeader := c.GetHeader("Authorization")
114
4x
        if authHeader != "" && strings.HasPrefix(authHeader, "Bearer ") {
115
3x
            rawKey = strings.TrimPrefix(authHeader, "Bearer ")
116
3x
        } else {
117
1x
            // Check for API key in query parameter
118
1x
            rawKey = c.Query("api_key")
119
1x
        }
120

121
4x
        if rawKey != "" {
122
3x
            // Validate API key
123
3x
            apiKey, err := apiKeyService.ValidateAPIKey(c.Request.Context(), rawKey)
124
3x
            if err == nil && apiKey != nil {
125
2x
                // Check permission level against request method
126
2x
                if !apiKey.CanPerformMethod(c.Request.Method) {
127
1x
                    c.JSON(http.StatusForbidden, gin.H{
128
1x
                        "error": "This API key does not have permission for this operation",
129
1x
                        "code":  "FORBIDDEN",
130
1x
                    })
131
1x
                    c.Abort()
132
1x
                    return
133
1x
                }
134

135
                // Get user info to set username in context
136
1x
                user, err := userService.GetUserByID(c.Request.Context(), apiKey.UserID)
137
1x
                if err != nil || user == nil {
138
                    c.JSON(http.StatusUnauthorized, gin.H{
139
                        "error": "Invalid API key - user not found",
140
                        "code":  "UNAUTHORIZED",
141
                    })
142
                    c.Abort()
143
                    return
144
                }
145

146
                // Set user context
147
1x
                c.Set(UserIDKey, apiKey.UserID)
148
1x
                c.Set(UsernameKey, user.Username)
149
1x
                c.Set(AuthMethodKey, AuthMethodAPIKey)
150
1x
                c.Set(APIKeyIDKey, apiKey.ID)
151
1x

152
1x
                // Update last used timestamp asynchronously
153
1x
                go func() {
154
1x
                    _ = apiKeyService.UpdateLastUsed(context.Background(), apiKey.ID)
155
1x
                }()
156

157
1x
                c.Next()
158
1x
                return
159
            }
160
            // If we got here with a key (from header or query), it's invalid
161
1x
            c.JSON(http.StatusUnauthorized, gin.H{
162
1x
                "error": "Invalid API key",
163
1x
                "code":  "UNAUTHORIZED",
164
1x
            })
165
1x
            c.Abort()
166
1x
            return
167
        }
168

169
        // Fall back to session authentication
170
1x
        session := sessions.Default(c)
171
1x
        userID := session.Get(UserIDKey)
172
1x

173
1x
        if userID == nil {
174
            c.JSON(http.StatusUnauthorized, gin.H{
175
                "error": "Authentication required",
176
                "code":  "UNAUTHORIZED",
177
            })
178
            c.Abort()
179
            return
180
        }
181

182
        // Validate user_id is an integer
183
1x
        userIDInt, ok := userID.(int)
184
1x
        if !ok {
185
            // Try to convert from float64 (JSON numbers are often stored as float64)
186
            if userIDFloat, ok := userID.(float64); ok {
187
                userIDInt = int(userIDFloat)
188
            } else {
189
                c.JSON(http.StatusUnauthorized, gin.H{
190
                    "error": "Authentication required",
191
                    "code":  "UNAUTHORIZED",
192
                })
193
                c.Abort()
194
                return
195
            }
196
        }
197

198
        // Validate username is a string and not empty
199
1x
        username := session.Get(UsernameKey)
200
1x
        if username == nil {
201
            c.JSON(http.StatusUnauthorized, gin.H{
202
                "error": "Authentication required",
203
                "code":  "UNAUTHORIZED",
204
            })
205
            c.Abort()
206
            return
207
        }
208

209
1x
        usernameStr, ok := username.(string)
210
1x
        if !ok || usernameStr == "" {
211
            c.JSON(http.StatusUnauthorized, gin.H{
212
                "error": "Authentication required",
213
                "code":  "UNAUTHORIZED",
214
            })
215
            c.Abort()
216
            return
217
        }
218

219
        // Store user info in context for handlers to use
220
1x
        c.Set(UserIDKey, userIDInt)
221
1x
        c.Set(UsernameKey, usernameStr)
222
1x
        c.Set(AuthMethodKey, AuthMethodSession)
223
1x

224
1x
        c.Next()
225
    }
226
}
227

228
// RequireAdmin returns a middleware that requires authentication and admin role
229
4x
func RequireAdmin(userService interface{}) gin.HandlerFunc {
230
4x
    // Type assertion to get the user service
231
4x
    us, ok := userService.(interface {
232
4x
        IsAdmin(ctx context.Context, userID int) (bool, error)
233
4x
    })
234
4x
    if !ok {
235
        panic("userService must implement IsAdmin method")
236
    }
237

238
4x
    return func(c *gin.Context) {
239
4x
        // First check authentication
240
4x
        session := sessions.Default(c)
241
4x
        userID := session.Get(UserIDKey)
242
4x

243
4x
        if userID == nil {
244
1x
            c.JSON(http.StatusUnauthorized, gin.H{
245
1x
                "error": "Authentication required",
246
1x
                "code":  "UNAUTHORIZED",
247
1x
            })
248
1x
            c.Abort()
249
1x
            return
250
1x
        }
251

252
        // Validate user_id is an integer
253
3x
        userIDInt, ok := userID.(int)
254
3x
        if !ok {
255
            // Try to convert from float64 (JSON numbers are often stored as float64)
256
            if userIDFloat, ok := userID.(float64); ok {
257
                userIDInt = int(userIDFloat)
258
            } else {
259
                c.JSON(http.StatusUnauthorized, gin.H{
260
                    "error": "Authentication required",
261
                    "code":  "UNAUTHORIZED",
262
                })
263
                c.Abort()
264
                return
265
            }
266
        }
267

268
        // Validate username is a string and not empty
269
3x
        username := session.Get(UsernameKey)
270
3x
        if username == nil {
271
            c.JSON(http.StatusUnauthorized, gin.H{
272
                "error": "Authentication required",
273
                "code":  "UNAUTHORIZED",
274
            })
275
            c.Abort()
276
            return
277
        }
278

279
3x
        usernameStr, ok := username.(string)
280
3x
        if !ok || usernameStr == "" {
281
            c.JSON(http.StatusUnauthorized, gin.H{
282
                "error": "Authentication required",
283
                "code":  "UNAUTHORIZED",
284
            })
285
            c.Abort()
286
            return
287
        }
288

289
        // Check if user has admin role
290
3x
        isAdmin, err := us.IsAdmin(c.Request.Context(), userIDInt)
291
3x
        if err != nil {
292
1x
            c.JSON(http.StatusInternalServerError, gin.H{
293
1x
                "error": "Failed to check admin status",
294
1x
                "code":  "INTERNAL_ERROR",
295
1x
            })
296
1x
            c.Abort()
297
1x
            return
298
1x
        }
299

300
2x
        if !isAdmin {
301
1x
            c.JSON(http.StatusForbidden, gin.H{
302
1x
                "error": "Admin access required",
303
1x
                "code":  "FORBIDDEN",
304
1x
            })
305
1x
            c.Abort()
306
1x
            return
307
1x
        }
308

309
        // Store user info in context for handlers to use
310
1x
        c.Set(UserIDKey, userIDInt)
311
1x
        c.Set(UsernameKey, usernameStr)
312
1x

313
1x
        c.Next()
314
    }
315
}
316


			
quizapp internal middleware validation.go
51.6%
Statements
48/93
1
package middleware
2

3
import (
4
    "fmt"
5
    "net/http"
6
    "runtime/debug"
7
    "time"
8

9
    contextutils "quizapp/internal/utils"
10

11
    "github.com/gin-gonic/gin"
12
)
13

14
// ErrorRecoveryConfig configures error recovery behavior
15
type ErrorRecoveryConfig struct {
16
    // MaxRetries specifies the maximum number of retries for retryable errors
17
    MaxRetries int
18
    // RetryDelay specifies the base delay between retries
19
    RetryDelay time.Duration
20
    // MaxRetryDelay specifies the maximum delay between retries
21
    MaxRetryDelay time.Duration
22
    // EnableCircuitBreaker enables circuit breaker pattern
23
    EnableCircuitBreaker bool
24
    // CircuitBreakerThreshold specifies failure threshold for circuit breaker
25
    CircuitBreakerThreshold int
26
    // CircuitBreakerTimeout specifies how long to wait before retrying after circuit opens
27
    CircuitBreakerTimeout time.Duration
28
}
29

30
// DefaultErrorRecoveryConfig returns a default error recovery configuration
31
3x
func DefaultErrorRecoveryConfig() *ErrorRecoveryConfig {
32
3x
    return &ErrorRecoveryConfig{
33
3x
        MaxRetries:              3,
34
3x
        RetryDelay:              100 * time.Millisecond,
35
3x
        MaxRetryDelay:           5 * time.Second,
36
3x
        EnableCircuitBreaker:    false,
37
3x
        CircuitBreakerThreshold: 5,
38
3x
        CircuitBreakerTimeout:   30 * time.Second,
39
3x
    }
40
3x
}
41

42
// circuitBreakerState represents the state of a circuit breaker
43
type circuitBreakerState int
44

45
const (
46
    circuitClosed circuitBreakerState = iota
47
    circuitOpen
48
    circuitHalfOpen
49
)
50

51
// circuitBreaker tracks failures and manages circuit state
52
type circuitBreaker struct {
53
    state       circuitBreakerState
54
    failures    int
55
    lastFailure time.Time
56
    config      *ErrorRecoveryConfig
57
}
58

59
// newCircuitBreaker creates a new circuit breaker
60
1x
func newCircuitBreaker(config *ErrorRecoveryConfig) *circuitBreaker {
61
1x
    return &circuitBreaker{
62
1x
        state:  circuitClosed,
63
1x
        config: config,
64
1x
    }
65
1x
}
66

67
// canExecute checks if the circuit breaker allows execution
68
4x
func (cb *circuitBreaker) canExecute() bool {
69
4x
    switch cb.state {
70
2x
    case circuitClosed:
71
2x
        return true
72
2x
    case circuitOpen:
73
2x
        if time.Since(cb.lastFailure) > cb.config.CircuitBreakerTimeout {
74
1x
            cb.state = circuitHalfOpen
75
1x
            return true
76
1x
        }
77
1x
        return false
78
    case circuitHalfOpen:
79
        return true
80
    default:
81
        return false
82
    }
83
}
84

85
// recordSuccess records a successful execution
86
1x
func (cb *circuitBreaker) recordSuccess() {
87
1x
    cb.failures = 0
88
1x
    cb.state = circuitClosed
89
1x
}
90

91
// recordFailure records a failed execution
92
2x
func (cb *circuitBreaker) recordFailure() {
93
2x
    cb.failures++
94
2x
    cb.lastFailure = time.Now()
95
2x

96
2x
    if cb.failures >= cb.config.CircuitBreakerThreshold {
97
1x
        cb.state = circuitOpen
98
1x
    }
99
}
100

101
// ErrorRecoveryMiddleware creates middleware for handling panics and retrying failed requests
102
2x
func ErrorRecoveryMiddleware(logger interface{}, config *ErrorRecoveryConfig) gin.HandlerFunc {
103
2x
    if config == nil {
104
2x
        config = DefaultErrorRecoveryConfig()
105
2x
    }
106

107
    // Create circuit breaker if enabled
108
2x
    var cb *circuitBreaker
109
2x
    if config.EnableCircuitBreaker {
110
        cb = newCircuitBreaker(config)
111
    }
112

113
2x
    return func(c *gin.Context) {
114
2x
        defer func() {
115
2x
            if err := recover(); err != nil {
116
1x
                // Log the panic with stack trace
117
1x
                stackTrace := string(debug.Stack())
118
1x
                fmt.Printf("Panic recovered: %v\nStack trace: %s\n", err, stackTrace)
119
1x

120
1x
                // Convert panic value to error if needed
121
1x
                var panicErr error
122
1x
                if e, ok := err.(error); ok {
123
                    panicErr = e
124
                } else {
125
1x
                    panicErr = contextutils.WrapErrorf(nil, "panic: %v", err)
126
1x
                }
127

128
                // Send error response
129
1x
                appErr := contextutils.NewAppErrorWithCause(
130
1x
                    contextutils.ErrorCodeInternalError,
131
1x
                    contextutils.SeverityFatal,
132
1x
                    "Internal server error",
133
1x
                    "A panic occurred while processing the request",
134
1x
                    contextutils.WrapError(panicErr, "panic"),
135
1x
                )
136
1x

137
1x
                // Add stack trace to error details in development
138
1x
                if gin.Mode() == gin.DebugMode {
139
                    appErr.Details = fmt.Sprintf("%s\nStack trace: %s", appErr.Details, stackTrace)
140
                }
141

142
1x
                HandleAppError(c, appErr)
143
1x
                c.Abort()
144
            }
145
        }()
146

147
        // Check circuit breaker
148
2x
        if cb != nil && !cb.canExecute() {
149
            ServiceUnavailable(c, "Service temporarily unavailable due to high error rate")
150
            c.Abort()
151
            return
152
        }
153

154
        // Process request
155
2x
        c.Next()
156
2x

157
2x
        // Record success/failure for circuit breaker
158
2x
        if cb != nil {
159
            if c.Writer.Status() >= 500 {
160
                cb.recordFailure()
161
            } else if c.Writer.Status() < 500 && cb.state == circuitHalfOpen {
162
                cb.recordSuccess()
163
            }
164
        }
165

166
        // Retry logic for failed requests
167
1x
        if shouldRetry(c.Writer.Status(), c.Errors) {
168
            retryWithBackoff(c, config, logger)
169
        }
170
    }
171
}
172

173
// shouldRetry determines if a request should be retried
174
6x
func shouldRetry(statusCode int, errors []*gin.Error) bool {
175
6x
    // Only retry 5xx errors and certain 4xx errors
176
6x
    if statusCode >= 500 {
177
1x
        return true
178
1x
    }
179

180
    // Retry on specific 4xx errors that might be transient
181
5x
    if statusCode == http.StatusRequestTimeout || statusCode == http.StatusTooManyRequests {
182
2x
        return true
183
2x
    }
184

185
    // Check if there are errors that indicate retryable failures
186
3x
    for _, err := range errors {
187
        if contextutils.IsRetryable(err) {
188
            return true
189
        }
190
    }
191

192
3x
    return false
193
}
194

195
// retryWithBackoff attempts to retry the request with exponential backoff
196
func retryWithBackoff(c *gin.Context, config *ErrorRecoveryConfig, logger interface{}) {
197
    // Only retry idempotent methods (GET, HEAD, OPTIONS, PUT, DELETE)
198
    method := c.Request.Method
199
    if method != http.MethodGet && method != http.MethodHead &&
200
        method != http.MethodOptions && method != http.MethodPut &&
201
        method != http.MethodDelete {
202
        return
203
    }
204

205
    // Get the original handler
206
    handlerName := c.HandlerName()
207
    if handlerName == "" {
208
        return
209
    }
210

211
    // Calculate retry delay with exponential backoff
212
    delay := config.RetryDelay
213
    for i := 0; i < config.MaxRetries; i++ {
214
        time.Sleep(delay)
215

216
        // Double the delay for next iteration (with max limit)
217
        delay *= 2
218
        if delay > config.MaxRetryDelay {
219
            delay = config.MaxRetryDelay
220
        }
221

222
        // Log retry attempt
223
        if logger != nil {
224
            // This would be logged using the observability logger in real implementation
225
            fmt.Printf("Retrying request %s %s (attempt %d/%d)\n",
226
                method, c.Request.URL.Path, i+1, config.MaxRetries)
227
        }
228

229
        // Note: In a real implementation, we would need to recreate the request
230
        // and re-execute it. This is a simplified version for demonstration.
231
        // The actual retry logic would depend on the specific use case.
232
    }
233
}
234

235
// HandleAppError handles any AppError and sends appropriate HTTP response
236
1x
func HandleAppError(c *gin.Context, err error) {
237
1x
    if appErr, ok := err.(*contextutils.AppError); ok {
238
1x
        StandardizeAppError(c, appErr)
239
1x
    } else {
240
        // Fallback for non-AppError types
241
        StandardizeHTTPError(c, http.StatusInternalServerError, "Internal server error", err.Error())
242
    }
243
}
244

245
// StandardizeAppError sends a structured error response using AppError
246
1x
func StandardizeAppError(c *gin.Context, err *contextutils.AppError) {
247
1x
    // Map error codes to HTTP status codes
248
1x
    statusCode := mapErrorCodeToHTTPStatus(err.Code)
249
1x

250
1x
    // Convert error to JSON structure
251
1x
    errorJSON := err.ToJSON()
252
1x

253
1x
    // Add retryable information based on error type
254
1x
    errorJSON["retryable"] = contextutils.IsRetryable(err)
255
1x

256
1x
    c.JSON(statusCode, errorJSON)
257
1x
}
258

259
// StandardizeHTTPError creates consistent HTTP error responses with structured error information
260
func StandardizeHTTPError(c *gin.Context, _ int, message, details string) {
261
    // Create a generic AppError for consistent response format
262
    appErr := contextutils.NewAppError(
263
        contextutils.ErrorCodeInternalError,
264
        contextutils.SeverityError,
265
        message,
266
        details,
267
    )
268

269
    StandardizeAppError(c, appErr)
270
}
271

272
// ServiceUnavailable sends a 503 Service Unavailable error with a standardized payload
273
func ServiceUnavailable(c *gin.Context, msg string) {
274
    appErr := contextutils.NewAppError(
275
        contextutils.ErrorCodeServiceUnavailable,
276
        contextutils.SeverityError,
277
        msg,
278
        "",
279
    )
280
    StandardizeAppError(c, appErr)
281
}
282

283
// mapErrorCodeToHTTPStatus maps AppError codes to appropriate HTTP status codes
284
1x
func mapErrorCodeToHTTPStatus(code contextutils.ErrorCode) int {
285
1x
    switch code {
286
    // 4xx Client Errors
287
    case contextutils.ErrorCodeInvalidInput, contextutils.ErrorCodeMissingRequired,
288
        contextutils.ErrorCodeInvalidFormat, contextutils.ErrorCodeValidationFailed,
289
        contextutils.ErrorCodeOAuthStateMismatch:
290
        return http.StatusBadRequest
291

292
    case contextutils.ErrorCodeUnauthorized:
293
        return http.StatusUnauthorized
294

295
    case contextutils.ErrorCodeForbidden:
296
        return http.StatusForbidden
297

298
    case contextutils.ErrorCodeRecordNotFound, contextutils.ErrorCodeQuestionNotFound,
299
        contextutils.ErrorCodeAssignmentNotFound:
300
        return http.StatusNotFound
301

302
    case contextutils.ErrorCodeRecordExists:
303
        return http.StatusConflict
304

305
    case contextutils.ErrorCodeSessionExpired, contextutils.ErrorCodeInvalidCredentials:
306
        return http.StatusUnauthorized
307

308
    case contextutils.ErrorCodeRateLimit:
309
        return http.StatusTooManyRequests
310

311
    // 5xx Server Errors
312
1x
    case contextutils.ErrorCodeInternalError:
313
1x
        return http.StatusInternalServerError
314

315
    case contextutils.ErrorCodeServiceUnavailable, contextutils.ErrorCodeDatabaseConnection,
316
        contextutils.ErrorCodeAIProviderUnavailable:
317
        return http.StatusServiceUnavailable
318

319
    case contextutils.ErrorCodeTimeout:
320
        return http.StatusRequestTimeout
321

322
    case contextutils.ErrorCodeDatabaseQuery, contextutils.ErrorCodeDatabaseTransaction,
323
        contextutils.ErrorCodeForeignKeyViolation, contextutils.ErrorCodeTimestampMissingTimezone,
324
        contextutils.ErrorCodeAIRequestFailed, contextutils.ErrorCodeAIResponseInvalid,
325
        contextutils.ErrorCodeAIConfigInvalid, contextutils.ErrorCodeOAuthProviderError:
326
        return http.StatusInternalServerError
327

328
    // Default to internal server error for unknown codes
329
    default:
330
        return http.StatusInternalServerError
331
    }
332
}
333


			
quizapp internal middleware validation.go
52.7%
Statements
221/419
1
package middleware
2

3
import (
4
    "encoding/json"
5
    "fmt"
6
    "os"
7
    "strings"
8

9
    contextutils "quizapp/internal/utils"
10

11
    "github.com/xeipuuv/gojsonschema"
12
    "gopkg.in/yaml.v2"
13
)
14

15
// SchemaLoader loads JSON schemas from the Swagger specification
16
type SchemaLoader struct {
17
    schemas               map[string]*gojsonschema.Schema
18
    jsonCompatibleSchemas map[string]interface{}
19
    swaggerData           map[string]interface{}
20
}
21

22
// NewSchemaLoader creates a new schema loader
23
1x
func NewSchemaLoader() *SchemaLoader {
24
1x
    return &SchemaLoader{
25
1x
        schemas:               make(map[string]*gojsonschema.Schema),
26
1x
        jsonCompatibleSchemas: make(map[string]interface{}),
27
1x
    }
28
1x
}
29

30
// LoadSchemasFromSwagger loads all schemas from the Swagger specification
31
func (sl *SchemaLoader) LoadSchemasFromSwagger(swaggerPath string) error {
32
    // Read the Swagger file
33
    data, err := os.ReadFile(swaggerPath)
34
    if err != nil {
35
        return contextutils.WrapError(err, "failed to read swagger file")
36
    }
37

38
    return sl.LoadSchemasFromSwaggerFromData(data)
39
}
40

41
// LoadSchemasFromSwaggerFromData loads all schemas from swagger data bytes
42
1x
func (sl *SchemaLoader) LoadSchemasFromSwaggerFromData(data []byte) error {
43
1x
    // Parse the Swagger spec (YAML only)
44
1x
    var swagger map[string]interface{}
45
1x

46
1x
    if err := yaml.Unmarshal(data, &swagger); err != nil {
47
        return contextutils.WrapError(err, "failed to parse swagger file as YAML")
48
    }
49

50
1x
    fmt.Printf("â Successfully parsed swagger file as YAML\n")
51
1x

52
1x
    // Store the parsed swagger data for later use
53
1x
    sl.swaggerData = swagger
54
1x

55
1x
    // Extract components/schemas
56
1x
    components, ok := swagger["components"].(map[interface{}]interface{})
57
1x
    if !ok {
58
        fmt.Printf("â No components section found. Available keys: %v\n", getKeys(swagger))
59
        fmt.Printf("â Components type: %T, value: %v\n", swagger["components"], swagger["components"])
60
        return contextutils.ErrorWithContextf("no components section found in swagger")
61
    }
62

63
1x
    schemas, ok := components["schemas"].(map[interface{}]interface{})
64
1x
    if !ok {
65
        fmt.Printf("â No schemas section found in components. Available keys: %v\n", getKeysInterface(components))
66
        fmt.Printf("â Schemas type: %T, value: %v\n", components["schemas"], components["schemas"])
67
        return contextutils.ErrorWithContextf("no schemas section found in swagger")
68
    }
69

70
    // Convert schemas to JSON-compatible format
71
1x
    jsonCompatibleSchemas := make(map[string]interface{})
72
1x
    for schemaName, schemaData := range schemas {
73
106x
        schemaNameStr, ok := schemaName.(string)
74
106x
        if !ok {
75
            fmt.Printf("Warning: schema name is not a string: %v\n", schemaName)
76
            continue
77
        }
78

79
106x
        convertedSchema := convertToJSONCompatible(schemaData)
80
106x

81
106x
        jsonCompatibleSchemas[schemaNameStr] = convertedSchema
82
    }
83

84
    // Store jsonCompatibleSchemas for creating array schemas later
85
1x
    sl.jsonCompatibleSchemas = jsonCompatibleSchemas
86
1x

87
1x
    // Load each schema
88
1x
    for schemaNameStr := range jsonCompatibleSchemas {
89
106x
        // Create a schema document with the full swagger context for $ref resolution
90
106x
        completeSchemaDoc := map[string]interface{}{
91
106x
            "$schema": "http://json-schema.org/draft-07/schema#",
92
106x
            "components": map[string]interface{}{
93
106x
                "schemas": jsonCompatibleSchemas,
94
106x
            },
95
106x
            "$ref": "#/components/schemas/" + schemaNameStr,
96
106x
        }
97
106x

98
106x
        schemaBytes, err := json.Marshal(completeSchemaDoc)
99
106x
        if err != nil {
100
            fmt.Printf("Warning: failed to marshal schema %s: %v\n", schemaNameStr, err)
101
            continue
102
        }
103

104
        // Load the schema
105
106x
        schemaLoader := gojsonschema.NewBytesLoader(schemaBytes)
106
106x
        schema, err := gojsonschema.NewSchema(schemaLoader)
107
106x
        if err != nil {
108
            fmt.Printf("Warning: failed to load schema %s: %v\n", schemaNameStr, err)
109
            continue
110
        }
111

112
106x
        sl.schemas[schemaNameStr] = schema
113
106x
        fmt.Printf("â Loaded schema: %s\n", schemaNameStr)
114
    }
115

116
1x
    return nil
117
}
118

119
// getKeys returns the keys of a map
120
func getKeys(m map[string]interface{}) []string {
121
    keys := make([]string, 0, len(m))
122
    for k := range m {
123
        keys = append(keys, k)
124
    }
125
    return keys
126
}
127

128
// getKeysInterface returns the keys of a map with interface{} keys
129
func getKeysInterface(m map[interface{}]interface{}) []string {
130
    keys := make([]string, 0, len(m))
131
    for k := range m {
132
        if keyStr, ok := k.(string); ok {
133
            keys = append(keys, keyStr)
134
        }
135
    }
136
    return keys
137
}
138

139
// convertInterfaceMapToStringMap converts a map[interface{}]interface{} to map[string]interface{}
140
463x
func convertInterfaceMapToStringMap(m map[interface{}]interface{}) map[string]interface{} {
141
463x
    result := make(map[string]interface{})
142
463x
    for k, v := range m {
143
58338x
        keyStr := fmt.Sprintf("%v", k) // Convert any key type to string
144
58338x
        result[keyStr] = convertToJSONCompatible(v)
145
58338x
    }
146
463x
    return result
147
}
148

149
// convertToJSONCompatible converts a map[interface{}]interface{} to map[string]interface{}
150
2916342x
func convertToJSONCompatible(data interface{}) interface{} {
151
2916342x
    switch v := data.(type) {
152
1446804x
    case map[interface{}]interface{}:
153
1446804x
        result := make(map[string]interface{})
154
1446804x
        hasNullable := false
155
1446804x

156
1446804x
        for k, val := range v {
157
2629026x
            keyStr := fmt.Sprintf("%v", k) // Convert any key type to string
158
2629026x

159
2629026x
            // Check for nullable field
160
2629026x
            if keyStr == "nullable" {
161
535x
                nullable, ok := val.(bool)
162
535x
                if ok && nullable {
163
535x
                    hasNullable = true
164
535x
                    continue // Skip the nullable field as we'll handle it in the type conversion
165
                }
166
            }
167

168
2628491x
            convertedVal := convertToJSONCompatible(val)
169
2628491x
            result[keyStr] = convertedVal
170
        }
171

172
        // Handle nullable fields by converting to union type
173
1446804x
        if hasNullable {
174
535x
            // If there's a $ref field, create a union type with null
175
535x
            if ref, hasRef := result["$ref"].(string); hasRef {
176
1x
                // Create a union type that allows both the referenced type and null
177
1x
                result["oneOf"] = []interface{}{
178
1x
                    map[string]interface{}{"$ref": ref},
179
1x
                    map[string]interface{}{"enum": []interface{}{nil}},
180
1x
                }
181
1x
                // Remove the original $ref field
182
1x
                delete(result, "$ref")
183
1x
            } else if typeVal, hasType := result["type"].(string); hasType {
184
                // If there's a type field, convert to array of types including null
185
534x
                result["type"] = []interface{}{typeVal, "null"}
186
534x
            }
187
        }
188

189
1446804x
        return result
190
242232x
    case []interface{}:
191
242232x
        result := make([]interface{}, len(v))
192
242232x
        for i, val := range v {
193
229407x
            convertedVal := convertToJSONCompatible(val)
194
229407x
            result[i] = convertedVal
195
229407x
        }
196
242232x
        return result
197
1227306x
    default:
198
1227306x
        return data
199
    }
200
}
201

202
// ValidateData validates data against a schema
203
3x
func (sl *SchemaLoader) ValidateData(data interface{}, schemaName string) error {
204
3x
    schema, exists := sl.schemas[schemaName]
205
3x
    if !exists {
206
        return contextutils.ErrorWithContextf("schema %s not found", schemaName)
207
    }
208

209
    // Convert data to JSON
210
3x
    jsonData, err := json.Marshal(data)
211
3x
    if err != nil {
212
        return contextutils.WrapError(err, "failed to marshal data")
213
    }
214

215
    // Create document loader
216
3x
    documentLoader := gojsonschema.NewBytesLoader(jsonData)
217
3x

218
3x
    // Validate
219
3x
    result, err := schema.Validate(documentLoader)
220
3x
    if err != nil {
221
        return contextutils.WrapError(err, "validation error")
222
    }
223

224
3x
    if !result.Valid() {
225
1x
        var validationErrors []string
226
1x
        for _, validationErr := range result.Errors() {
227
3x
            errorMsg := fmt.Sprintf("%s: %s", validationErr.Field(), validationErr.Description())
228
3x
            // Include the actual value that failed validation if available
229
3x
            if validationErr.Value() != nil {
230
3x
                errorMsg += fmt.Sprintf(" (received: %v)", validationErr.Value())
231
3x
            }
232
3x
            validationErrors = append(validationErrors, errorMsg)
233
        }
234
1x
        return contextutils.ErrorWithContextf("schema validation failed: %s", strings.Join(validationErrors, "; "))
235
    }
236

237
2x
    return nil
238
}
239

240
// AutoLoadSchemas automatically loads schemas from the swagger file path
241
func AutoLoadSchemas() *SchemaLoader {
242
    loader := NewSchemaLoader()
243

244
    // Get swagger file path from environment variable
245
    swaggerPath := os.Getenv("SWAGGER_FILE_PATH")
246
    if swaggerPath == "" {
247
        fmt.Printf("â SWAGGER_FILE_PATH environment variable not set\n")
248
        return loader
249
    }
250

251
    if _, err := os.Stat(swaggerPath); err == nil {
252
        if err := loader.LoadSchemasFromSwagger(swaggerPath); err != nil {
253
            fmt.Printf("Warning: failed to load schemas from %s: %v\n", swaggerPath, err)
254
        } else {
255
            fmt.Printf("â Successfully loaded schemas from %s\n", swaggerPath)
256
            return loader
257
        }
258
    } else {
259
        fmt.Printf("âï  Swagger file not found at %s: %v\n", swaggerPath, err)
260
    }
261

262
    return loader
263
}
264

265
// IsEndpointDocumented checks if an endpoint is documented in the swagger spec
266
155x
func (sl *SchemaLoader) IsEndpointDocumented(path, method string) bool {
267
155x
    // Use cached swagger data if available
268
155x
    if sl.swaggerData == nil {
269
        return false
270
    }
271
155x
    swagger := sl.swaggerData
272
155x

273
155x
    // Extract paths
274
155x
    paths, ok := swagger["paths"].(map[string]interface{})
275
155x
    if !ok {
276
155x
        // Try with interface{} keys
277
155x
        pathsInterface, ok := swagger["paths"].(map[interface{}]interface{})
278
155x
        if !ok {
279
            return false
280
        }
281
        // Convert to string keys
282
155x
        paths = convertInterfaceMapToStringMap(pathsInterface)
283
    }
284

285
    // First, try exact match
286
155x
    pathInfo, exists := paths[path]
287
155x
    if exists {
288
152x
        pathMap, ok := pathInfo.(map[string]interface{})
289
152x
        if !ok {
290
            // Try with interface{} keys
291
            pathMapInterface, ok := pathInfo.(map[interface{}]interface{})
292
            if !ok {
293
                return false
294
            }
295
            // Convert to string keys
296
            pathMap = convertInterfaceMapToStringMap(pathMapInterface)
297
        }
298

299
        // Look for the specific HTTP method
300
152x
        _, exists = pathMap[strings.ToLower(method)]
301
152x
        if exists {
302
152x
            return true
303
152x
        }
304
    }
305

306
    // If exact match fails, try pattern matching for path parameters
307
3x
    for swaggerPath := range paths {
308
198x
        if sl.pathMatchesPattern(path, swaggerPath) {
309
2x
            pathInfo := paths[swaggerPath]
310
2x
            pathMap, ok := pathInfo.(map[string]interface{})
311
2x
            if !ok {
312
                // Try with interface{} keys
313
                pathMapInterface, ok := pathInfo.(map[interface{}]interface{})
314
                if !ok {
315
                    continue
316
                }
317
                // Convert to string keys
318
                pathMap = convertInterfaceMapToStringMap(pathMapInterface)
319
            }
320

321
            // Look for the specific HTTP method
322
2x
            _, exists = pathMap[strings.ToLower(method)]
323
2x
            if exists {
324
2x
                return true
325
2x
            }
326
        }
327
    }
328

329
1x
    return false
330
}
331

332
// pathMatchesPattern checks if a request path matches a swagger path pattern
333
885x
func (sl *SchemaLoader) pathMatchesPattern(requestPath, swaggerPath string) bool {
334
885x
    // Split paths into segments
335
885x
    requestSegments := strings.Split(requestPath, "/")
336
885x
    swaggerSegments := strings.Split(swaggerPath, "/")
337
885x

338
885x
    // Paths must have the same number of segments
339
885x
    if len(requestSegments) != len(swaggerSegments) {
340
647x
        return false
341
647x
    }
342

343
    // Compare each segment
344
238x
    for i, swaggerSegment := range swaggerSegments {
345
742x
        requestSegment := requestSegments[i]
346
742x

347
742x
        // If swagger segment is a parameter (starts with { and ends with })
348
742x
        if strings.HasPrefix(swaggerSegment, "{") && strings.HasSuffix(swaggerSegment, "}") {
349
6x
            // Any value is acceptable for parameters
350
6x
            continue
351
        }
352

353
        // Otherwise, segments must match exactly
354
730x
        if swaggerSegment != requestSegment {
355
226x
            return false
356
226x
        }
357
    }
358

359
6x
    return true
360
}
361

362
// DetermineRequestSchemaFromPath automatically determines the schema name from the API path and method
363
154x
func (sl *SchemaLoader) DetermineRequestSchemaFromPath(path, method string) string {
364
154x
    // Use cached swagger data if available
365
154x
    if sl.swaggerData == nil {
366
        return ""
367
    }
368
154x
    swagger := sl.swaggerData
369
154x

370
154x
    // Extract paths
371
154x
    paths, ok := swagger["paths"].(map[string]interface{})
372
154x
    if !ok {
373
154x
        // Try with interface{} keys
374
154x
        pathsInterface, ok := swagger["paths"].(map[interface{}]interface{})
375
154x
        if !ok {
376
            return ""
377
        }
378
        // Convert to string keys
379
154x
        paths = convertInterfaceMapToStringMap(pathsInterface)
380
    }
381

382
    // First, try exact match
383
154x
    pathInfo, exists := paths[path]
384
154x
    if !exists {
385
2x
        // If exact match fails, try pattern matching for path parameters
386
2x
        for swaggerPath := range paths {
387
281x
            if sl.pathMatchesPattern(path, swaggerPath) {
388
2x
                pathInfo = paths[swaggerPath]
389
2x
                break
390
            }
391
        }
392
2x
        if pathInfo == nil {
393
            return ""
394
        }
395
    }
396

397
154x
    pathMap, ok := pathInfo.(map[string]interface{})
398
154x
    if !ok {
399
        // Try with interface{} keys
400
        pathMapInterface, ok := pathInfo.(map[interface{}]interface{})
401
        if !ok {
402
            return ""
403
        }
404
        // Convert to string keys
405
        pathMap = convertInterfaceMapToStringMap(pathMapInterface)
406
    }
407

408
    // Look for the specific HTTP method
409
154x
    methodInfo, exists := pathMap[strings.ToLower(method)]
410
154x
    if !exists {
411
        return ""
412
    }
413

414
154x
    methodMap, ok := methodInfo.(map[string]interface{})
415
154x
    if !ok {
416
        // Try with interface{} keys
417
        methodMapInterface, ok := methodInfo.(map[interface{}]interface{})
418
        if !ok {
419
            return ""
420
        }
421
        // Convert to string keys
422
        methodMap = convertInterfaceMapToStringMap(methodMapInterface)
423
    }
424

425
    // Extract the request body schema
426
154x
    requestBody, exists := methodMap["requestBody"]
427
154x
    if !exists {
428
113x
        return ""
429
113x
    }
430

431
41x
    requestBodyMap, ok := requestBody.(map[string]interface{})
432
41x
    if !ok {
433
        // Try with interface{} keys
434
        requestBodyMapInterface, ok := requestBody.(map[interface{}]interface{})
435
        if !ok {
436
            return ""
437
        }
438
        // Convert to string keys
439
        requestBodyMap = convertInterfaceMapToStringMap(requestBodyMapInterface)
440
    }
441

442
    // Extract content
443
41x
    content, ok := requestBodyMap["content"].(map[string]interface{})
444
41x
    if !ok {
445
        // Try with interface{} keys
446
        contentInterface, ok := requestBodyMap["content"].(map[interface{}]interface{})
447
        if !ok {
448
            return ""
449
        }
450
        // Convert to string keys
451
        content = convertInterfaceMapToStringMap(contentInterface)
452
    }
453

454
    // Look for application/json content
455
41x
    jsonContent, exists := content["application/json"]
456
41x
    if !exists {
457
        return ""
458
    }
459

460
41x
    jsonContentMap, ok := jsonContent.(map[string]interface{})
461
41x
    if !ok {
462
        // Try with interface{} keys
463
        jsonContentMapInterface, ok := jsonContent.(map[interface{}]interface{})
464
        if !ok {
465
            return ""
466
        }
467
        // Convert to string keys
468
        jsonContentMap = convertInterfaceMapToStringMap(jsonContentMapInterface)
469
    }
470

471
    // Extract schema
472
41x
    schema, exists := jsonContentMap["schema"]
473
41x
    if !exists {
474
        return ""
475
    }
476

477
41x
    schemaMap, ok := schema.(map[string]interface{})
478
41x
    if !ok {
479
        // Try with interface{} keys
480
        schemaMapInterface, ok := schema.(map[interface{}]interface{})
481
        if !ok {
482
            return ""
483
        }
484
        // Convert to string keys
485
        schemaMap = convertInterfaceMapToStringMap(schemaMapInterface)
486
    }
487

488
    // Extract $ref
489
41x
    ref, exists := schemaMap["$ref"]
490
41x
    if !exists {
491
9x
        return ""
492
9x
    }
493

494
32x
    refStr, ok := ref.(string)
495
32x
    if !ok {
496
        return ""
497
    }
498

499
    // Extract schema name from $ref
500
    // $ref format: "#/components/schemas/SchemaName"
501
32x
    parts := strings.Split(refStr, "/")
502
32x
    if len(parts) < 4 {
503
        return ""
504
    }
505

506
32x
    return parts[len(parts)-1]
507
}
508

509
// DetermineSchemaFromPath determines the schema name for a given path and HTTP method
510
// by parsing the swagger file and looking up the response schema for the 200 status code.
511
154x
func (sl *SchemaLoader) DetermineSchemaFromPath(path, method string) string {
512
154x
    // Use cached swagger data if available
513
154x
    if sl.swaggerData == nil {
514
        return ""
515
    }
516
154x
    swagger := sl.swaggerData
517
154x

518
154x
    // Extract paths
519
154x
    paths, ok := swagger["paths"].(map[string]interface{})
520
154x
    if !ok {
521
154x
        // Try with interface{} keys
522
154x
        pathsInterface, ok := swagger["paths"].(map[interface{}]interface{})
523
154x
        if !ok {
524
            return ""
525
        }
526
        // Convert to string keys
527
154x
        paths = convertInterfaceMapToStringMap(pathsInterface)
528
    }
529

530
    // First, try exact match
531
154x
    pathInfo, exists := paths[path]
532
154x
    if !exists {
533
2x
        // If exact match fails, try pattern matching for path parameters
534
2x
        for swaggerPath := range paths {
535
208x
            if sl.pathMatchesPattern(path, swaggerPath) {
536
2x
                pathInfo = paths[swaggerPath]
537
2x
                break
538
            }
539
        }
540
2x
        if pathInfo == nil {
541
            return ""
542
        }
543
    }
544

545
154x
    pathMap, ok := pathInfo.(map[string]interface{})
546
154x
    if !ok {
547
        // Try with interface{} keys
548
        pathMapInterface, ok := pathInfo.(map[interface{}]interface{})
549
        if !ok {
550
            return ""
551
        }
552
        // Convert to string keys
553
        pathMap = convertInterfaceMapToStringMap(pathMapInterface)
554
    }
555

556
    // Look for the specific HTTP method
557
154x
    methodInfo, exists := pathMap[strings.ToLower(method)]
558
154x
    if !exists {
559
        return ""
560
    }
561

562
154x
    methodMap, ok := methodInfo.(map[string]interface{})
563
154x
    if !ok {
564
        // Try with interface{} keys
565
        methodMapInterface, ok := methodInfo.(map[interface{}]interface{})
566
        if !ok {
567
            return ""
568
        }
569
        // Convert to string keys
570
        methodMap = convertInterfaceMapToStringMap(methodMapInterface)
571
    }
572

573
    // Extract the response schema
574
154x
    responses, ok := methodMap["responses"].(map[string]interface{})
575
154x
    if !ok {
576
        // Try with interface{} keys
577
        responsesInterface, ok := methodMap["responses"].(map[interface{}]interface{})
578
        if !ok {
579
            return ""
580
        }
581
        // Convert to string keys
582
        responses = convertInterfaceMapToStringMap(responsesInterface)
583
    }
584

585
    // Look for success response (try 200, 201, etc.)
586
154x
    var successResponse interface{}
587
154x

588
154x
    // Try common success status codes in order of preference
589
154x
    successCodes := []string{"200", "201", "202"}
590
154x
    for _, code := range successCodes {
591
174x
        if resp, exists := responses[code]; exists {
592
149x
            successResponse = resp
593
149x
            break
594
        }
595
    }
596

597
154x
    if successResponse == nil {
598
5x
        return ""
599
5x
    }
600

601
149x
    responseMap, ok := successResponse.(map[string]interface{})
602
149x
    if !ok {
603
        // Try with interface{} keys
604
        responseMapInterface, ok := successResponse.(map[interface{}]interface{})
605
        if !ok {
606
            return ""
607
        }
608
        // Convert to string keys
609
        responseMap = convertInterfaceMapToStringMap(responseMapInterface)
610
    }
611

612
    // Extract content
613
149x
    content, ok := responseMap["content"].(map[string]interface{})
614
149x
    if !ok {
615
4x
        // Try with interface{} keys
616
4x
        contentInterface, ok := responseMap["content"].(map[interface{}]interface{})
617
4x
        if !ok {
618
4x
            return ""
619
4x
        }
620
        // Convert to string keys
621
        content = convertInterfaceMapToStringMap(contentInterface)
622
    }
623

624
    // Look for application/json
625
145x
    jsonContent, exists := content["application/json"]
626
145x
    if !exists {
627
7x
        return ""
628
7x
    }
629

630
138x
    jsonMap, ok := jsonContent.(map[string]interface{})
631
138x
    if !ok {
632
        // Try with interface{} keys
633
        jsonMapInterface, ok := jsonContent.(map[interface{}]interface{})
634
        if !ok {
635
            return ""
636
        }
637
        // Convert to string keys
638
        jsonMap = convertInterfaceMapToStringMap(jsonMapInterface)
639
    }
640

641
    // Extract schema reference
642
138x
    schema, exists := jsonMap["schema"]
643
138x
    if !exists {
644
        return ""
645
    }
646

647
138x
    schemaMap, ok := schema.(map[string]interface{})
648
138x
    if !ok {
649
        // Try with interface{} keys
650
        schemaMapInterface, ok := schema.(map[interface{}]interface{})
651
        if !ok {
652
            return ""
653
        }
654
        // Convert to string keys
655
        schemaMap = convertInterfaceMapToStringMap(schemaMapInterface)
656
    }
657

658
    // Extract $ref directly
659
138x
    if ref, exists := schemaMap["$ref"]; exists {
660
78x
        if refStr, ok := ref.(string); ok {
661
78x
            // Extract schema name from $ref (e.g., "#/components/schemas/DashboardResponse")
662
78x
            if strings.HasPrefix(refStr, "#/components/schemas/") {
663
78x
                schemaName := strings.TrimPrefix(refStr, "#/components/schemas/")
664
78x
                return schemaName
665
78x
            }
666
        }
667
    }
668

669
    // Handle array schemas - check if it's an array with items that have a $ref
670
60x
    if schemaType, exists := schemaMap["type"]; exists {
671
59x
        if typeStr, ok := schemaType.(string); ok && typeStr == "array" {
672
5x
            // Check for items.$ref
673
5x
            if items, exists := schemaMap["items"]; exists {
674
5x
                itemsMap, ok := items.(map[string]interface{})
675
5x
                if !ok {
676
                    // Try with interface{} keys
677
                    itemsMapInterface, ok := items.(map[interface{}]interface{})
678
                    if !ok {
679
                        return ""
680
                    }
681
                    itemsMap = convertInterfaceMapToStringMap(itemsMapInterface)
682
                }
683

684
5x
                if ref, exists := itemsMap["$ref"]; exists {
685
5x
                    if refStr, ok := ref.(string); ok {
686
5x
                        // Extract schema name from $ref (e.g., "#/components/schemas/Story")
687
5x
                        if strings.HasPrefix(refStr, "#/components/schemas/") {
688
5x
                            itemSchemaName := strings.TrimPrefix(refStr, "#/components/schemas/")
689
5x

690
5x
                            // For array responses, we need to create a synthetic schema that validates arrays
691
5x
                            arraySchemaName := fmt.Sprintf("%sArray", itemSchemaName)
692
5x

693
5x
                            // Check if we've already created this array schema
694
5x
                            if _, exists := sl.schemas[arraySchemaName]; !exists {
695
4x
                                // Create array schema with full context for $ref resolution
696
4x
                                arraySchema := map[string]interface{}{
697
4x
                                    "$schema": "http://json-schema.org/draft-07/schema#",
698
4x
                                    "components": map[string]interface{}{
699
4x
                                        "schemas": sl.jsonCompatibleSchemas,
700
4x
                                    },
701
4x
                                    "type": "array",
702
4x
                                    "items": map[string]interface{}{
703
4x
                                        "$ref": fmt.Sprintf("#/components/schemas/%s", itemSchemaName),
704
4x
                                    },
705
4x
                                }
706
4x

707
4x
                                // Load the array schema
708
4x
                                schemaBytes, err := json.Marshal(arraySchema)
709
4x
                                if err != nil {
710
                                    fmt.Printf("Warning: failed to marshal array schema %s: %v\n", arraySchemaName, err)
711
                                    return itemSchemaName // Fallback to item schema
712
                                }
713

714
4x
                                schemaLoader := gojsonschema.NewBytesLoader(schemaBytes)
715
4x
                                schema, err := gojsonschema.NewSchema(schemaLoader)
716
4x
                                if err != nil {
717
                                    fmt.Printf("Warning: failed to load array schema %s: %v\n", arraySchemaName, err)
718
                                    return itemSchemaName // Fallback to item schema
719
                                }
720

721
4x
                                sl.schemas[arraySchemaName] = schema
722
4x
                                fmt.Printf("â Created array schema: %s\n", arraySchemaName)
723
                            }
724

725
5x
                            return arraySchemaName
726
                        }
727
                    }
728
                }
729
            }
730
        }
731
    }
732

733
55x
    return ""
734
}
735

736
// DetermineResponseSchemaFromPath determines the schema name for a given path, method, and HTTP status code
737
func (sl *SchemaLoader) DetermineResponseSchemaFromPath(path, method, statusCode string) string {
738
    // Use cached swagger data if available
739
    if sl.swaggerData == nil {
740
        return ""
741
    }
742
    swagger := sl.swaggerData
743

744
    // Extract paths
745
    paths, ok := swagger["paths"].(map[string]interface{})
746
    if !ok {
747
        // Try with interface{} keys
748
        pathsInterface, ok := swagger["paths"].(map[interface{}]interface{})
749
        if !ok {
750
            return ""
751
        }
752
        // Convert to string keys
753
        paths = convertInterfaceMapToStringMap(pathsInterface)
754
    }
755

756
    // First, try exact match
757
    pathInfo, exists := paths[path]
758
    if !exists {
759
        // If exact match fails, try pattern matching for path parameters
760
        for swaggerPath := range paths {
761
            if sl.pathMatchesPattern(path, swaggerPath) {
762
                pathInfo = paths[swaggerPath]
763
                break
764
            }
765
        }
766
        if pathInfo == nil {
767
            return ""
768
        }
769
    }
770

771
    pathMap, ok := pathInfo.(map[string]interface{})
772
    if !ok {
773
        // Try with interface{} keys
774
        pathMapInterface, ok := pathInfo.(map[interface{}]interface{})
775
        if !ok {
776
            return ""
777
        }
778
        // Convert to string keys
779
        pathMap = convertInterfaceMapToStringMap(pathMapInterface)
780
    }
781

782
    // Look for the specific HTTP method
783
    methodInfo, exists := pathMap[strings.ToLower(method)]
784
    if !exists {
785
        return ""
786
    }
787

788
    methodMap, ok := methodInfo.(map[string]interface{})
789
    if !ok {
790
        // Try with interface{} keys
791
        methodMapInterface, ok := methodInfo.(map[interface{}]interface{})
792
        if !ok {
793
            return ""
794
        }
795
        // Convert to string keys
796
        methodMap = convertInterfaceMapToStringMap(methodMapInterface)
797
    }
798

799
    // Extract the response schema map
800
    responses, ok := methodMap["responses"].(map[string]interface{})
801
    if !ok {
802
        // Try with interface{} keys
803
        responsesInterface, ok := methodMap["responses"].(map[interface{}]interface{})
804
        if !ok {
805
            return ""
806
        }
807
        // Convert to string keys
808
        responses = convertInterfaceMapToStringMap(responsesInterface)
809
    }
810

811
    // Get response for the exact status code
812
    successResponse, exists := responses[statusCode]
813
    if !exists {
814
        return ""
815
    }
816

817
    responseMap, ok := successResponse.(map[string]interface{})
818
    if !ok {
819
        // Try with interface{} keys
820
        responseMapInterface, ok := successResponse.(map[interface{}]interface{})
821
        if !ok {
822
            return ""
823
        }
824
        // Convert to string keys
825
        responseMap = convertInterfaceMapToStringMap(responseMapInterface)
826
    }
827

828
    // Extract content
829
    content, ok := responseMap["content"].(map[string]interface{})
830
    if !ok {
831
        // Try with interface{} keys
832
        contentInterface, ok := responseMap["content"].(map[interface{}]interface{})
833
        if !ok {
834
            return ""
835
        }
836
        // Convert to string keys
837
        content = convertInterfaceMapToStringMap(contentInterface)
838
    }
839

840
    // Look for application/json
841
    jsonContent, exists := content["application/json"]
842
    if !exists {
843
        return ""
844
    }
845

846
    jsonMap, ok := jsonContent.(map[string]interface{})
847
    if !ok {
848
        // Try with interface{} keys
849
        jsonMapInterface, ok := jsonContent.(map[interface{}]interface{})
850
        if !ok {
851
            return ""
852
        }
853
        // Convert to string keys
854
        jsonMap = convertInterfaceMapToStringMap(jsonMapInterface)
855
    }
856

857
    // Extract schema reference
858
    schema, exists := jsonMap["schema"]
859
    if !exists {
860
        return ""
861
    }
862

863
    schemaMap, ok := schema.(map[string]interface{})
864
    if !ok {
865
        // Try with interface{} keys
866
        schemaMapInterface, ok := schema.(map[interface{}]interface{})
867
        if !ok {
868
            return ""
869
        }
870
        // Convert to string keys
871
        schemaMap = convertInterfaceMapToStringMap(schemaMapInterface)
872
    }
873

874
    // Extract $ref directly
875
    if ref, exists := schemaMap["$ref"]; exists {
876
        if refStr, ok := ref.(string); ok {
877
            if strings.HasPrefix(refStr, "#/components/schemas/") {
878
                schemaName := strings.TrimPrefix(refStr, "#/components/schemas/")
879
                return schemaName
880
            }
881
        }
882
    }
883

884
    return ""
885
}
886


			
quizapp internal middleware validation.go
0.0%
Statements
0/114
1
package middleware
2

3
import (
4
    "bytes"
5
    "encoding/json"
6
    "fmt"
7
    "io"
8
    "math"
9
    "net/http"
10
    "strings"
11

12
    "quizapp/internal/observability"
13

14
    "github.com/gin-gonic/gin"
15
)
16

17
// Global schema loader instance
18
var globalSchemaLoader *SchemaLoader
19

20
// initSchemaLoader initializes the global schema loader once
21
func initSchemaLoader() *SchemaLoader {
22
    if globalSchemaLoader == nil {
23
        globalSchemaLoader = AutoLoadSchemas()
24
    }
25
    return globalSchemaLoader
26
}
27

28
// ResponseValidationMiddleware creates middleware that automatically validates responses
29
func ResponseValidationMiddleware(logger *observability.Logger) gin.HandlerFunc {
30
    // Initialize schema loader once
31
    schemaLoader := initSchemaLoader()
32

33
    return func(c *gin.Context) {
34
        // Start tracing span for validation
35
        ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "response_validation")
36
        defer span.End()
37

38
        // Store the original response writer
39
        originalWriter := c.Writer
40

41
        // Create a custom response writer that captures the response
42
        responseWriter := &responseCaptureWriter{
43
            ResponseWriter: originalWriter,
44
            body:           &bytes.Buffer{},
45
            status:         0,
46
        }
47

48
        // Replace the response writer
49
        c.Writer = responseWriter
50

51
        // Continue to the next handler
52
        c.Next()
53

54
        // After the response is written, validate it
55
        statusCode := responseWriter.status
56
        if statusCode == 0 {
57
            statusCode = c.Writer.Status()
58
        }
59

60
        // Only validate 2xx responses
61
        if statusCode >= http.StatusOK && statusCode < http.StatusMultipleChoices {
62
            // Skip validation for streaming responses
63
            contentType := c.Writer.Header().Get("Content-Type")
64
            if contentType == "text/event-stream" {
65
                span.SetAttributes(
66
                    observability.AttributeTypeFilter("streaming_response"),
67
                )
68
                logger.Debug(ctx, "Skipping validation for streaming response", map[string]interface{}{
69
                    "method": c.Request.Method,
70
                    "path":   c.Request.URL.Path,
71
                })
72
                // Write the buffered response to the real writer
73
                c.Writer = originalWriter
74
                c.Writer.WriteHeader(statusCode)
75
                _, _ = c.Writer.Write(responseWriter.body.Bytes())
76
                return
77
            }
78

79
            // Try to parse the response as JSON
80
            var responseData interface{}
81
            err := json.Unmarshal(responseWriter.body.Bytes(), &responseData)
82
            if err == nil {
83
                // Determine schema name from the endpoint for the actual status code
84
                schemaName := schemaLoader.DetermineResponseSchemaFromPath(c.Request.URL.Path, c.Request.Method, fmt.Sprintf("%d", statusCode))
85
                if schemaName == "" {
86
                    // Fallback to generic success schema resolution if exact status not found
87
                    schemaName = schemaLoader.DetermineSchemaFromPath(c.Request.URL.Path, c.Request.Method)
88
                }
89

90
                // Add tracing attributes
91
                span.SetAttributes(
92
                    observability.AttributeSearch(c.Request.URL.Path),
93
                    observability.AttributeTypeFilter(c.Request.Method),
94
                )
95

96
                if schemaName != "" {
97
                    span.SetAttributes(observability.AttributeSearch(schemaName))
98

99
                    if err := schemaLoader.ValidateData(responseData, schemaName); err != nil {
100
                        // Log the validation error and add tracing attributes
101
                        span.SetAttributes(
102
                            observability.AttributeTypeFilter("validation_failed"),
103
                        )
104

105
                        // Log the validation error and fail the request
106
                        logger.Error(ctx, "Response validation failed", err, map[string]interface{}{
107
                            "method":        c.Request.Method,
108
                            "path":          c.Request.URL.Path,
109
                            "schema_name":   schemaName,
110
                            "error":         err.Error(),
111
                            "response_data": responseWriter.body.String()[:int(math.Min(200, float64(responseWriter.body.Len())))],
112
                        })
113

114
                        // Write a 400 error response instead of the original response
115
                        c.Writer = originalWriter
116
                        c.Writer.WriteHeader(http.StatusBadRequest)
117
                        _ = json.NewEncoder(c.Writer).Encode(gin.H{
118
                            "error":   "Response validation failed",
119
                            "message": "API response does not match the specification",
120
                            "method":  c.Request.Method,
121
                            "path":    c.Request.URL.Path,
122
                            "schema":  schemaName,
123
                            "details": err.Error(),
124
                        })
125
                        return
126
                    }
127
                    // Add success tracing attributes
128
                    span.SetAttributes(
129
                        observability.AttributeTypeFilter("validation_passed"),
130
                    )
131

132
                    // Write the buffered response to the real writer
133
                    c.Writer = originalWriter
134
                    c.Writer.WriteHeader(statusCode)
135
                    _, _ = c.Writer.Write(responseWriter.body.Bytes())
136
                    return
137
                }
138
                // No schema found for this endpoint
139
                span.SetAttributes(
140
                    observability.AttributeTypeFilter("no_schema_found"),
141
                )
142

143
                logger.Warn(ctx, "No schema found for endpoint", map[string]interface{}{
144
                    "method": c.Request.Method,
145
                    "path":   c.Request.URL.Path,
146
                })
147
                // Write the buffered response to the real writer
148
                c.Writer = originalWriter
149
                c.Writer.WriteHeader(statusCode)
150
                _, _ = c.Writer.Write(responseWriter.body.Bytes())
151
                return
152
            }
153
            // Failed to parse JSON response
154
            span.SetAttributes(
155
                observability.AttributeTypeFilter("json_parse_failed"),
156
            )
157

158
            logger.Error(ctx, "Failed to parse JSON response", err, map[string]interface{}{
159
                "method": c.Request.Method,
160
                "path":   c.Request.URL.Path,
161
            })
162
            // Write the buffered response to the real writer
163
            c.Writer = originalWriter
164
            c.Writer.WriteHeader(statusCode)
165
            _, _ = c.Writer.Write(responseWriter.body.Bytes())
166
            return
167
        }
168
        // Non-200 status code, skip validation
169
        span.SetAttributes(
170
            observability.AttributeTypeFilter("non_200_status"),
171
        )
172
        // Write the buffered response to the real writer
173
        c.Writer = originalWriter
174
        c.Writer.WriteHeader(statusCode)
175
        _, _ = c.Writer.Write(responseWriter.body.Bytes())
176
    }
177
}
178

179
// responseCaptureWriter captures the response body for validation
180
// Add a status field to track the status code
181
type responseCaptureWriter struct {
182
    gin.ResponseWriter
183
    body   *bytes.Buffer
184
    status int
185
}
186

187
func (w *responseCaptureWriter) WriteHeader(statusCode int) {
188
    w.status = statusCode
189
    w.ResponseWriter.WriteHeader(statusCode)
190
}
191

192
func (w *responseCaptureWriter) Write(b []byte) (int, error) {
193
    return w.body.Write(b)
194
}
195

196
func (w *responseCaptureWriter) Status() int {
197
    if w.status != 0 {
198
        return w.status
199
    }
200
    return w.ResponseWriter.Status()
201
}
202

203
// isStaticFile checks if a path is a static file that should be allowed to pass through
204
func isStaticFile(path string) bool {
205
    staticPaths := []string{
206
        "/swagger.yaml",
207
        "/swaggerz",
208
        "/configz",
209
        "/",
210
    }
211

212
    for _, staticPath := range staticPaths {
213
        if path == staticPath {
214
            return true
215
        }
216
    }
217

218
    // Also allow paths that start with /backend/ (static assets)
219
    if strings.HasPrefix(path, "/backend/") {
220
        return true
221
    }
222

223
    return false
224
}
225

226
// RequestValidationMiddleware creates middleware that prevents undocumented API calls
227
func RequestValidationMiddleware(logger *observability.Logger) gin.HandlerFunc {
228
    // Initialize schema loader once
229
    schemaLoader := initSchemaLoader()
230

231
    return func(c *gin.Context) {
232
        // Start tracing span for request validation
233
        ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "request_validation")
234
        defer span.End()
235

236
        // Check if the endpoint exists in the swagger spec
237
        path := c.Request.URL.Path
238
        method := c.Request.Method
239

240
        // Log all requests for debugging
241
        logger.Info(ctx, "Request validation middleware called", map[string]interface{}{
242
            "method": method,
243
            "path":   path,
244
        })
245

246
        // Add tracing attributes
247
        span.SetAttributes(
248
            observability.AttributeSearch(path),
249
            observability.AttributeTypeFilter(method),
250
        )
251

252
        // Allow static files to pass through
253
        if isStaticFile(path) {
254
            // Continue to the next handler
255
            c.Next()
256
            return
257
        }
258

259
        // Check if this endpoint is documented in swagger
260
        if !schemaLoader.IsEndpointDocumented(path, method) {
261
            // Log the undocumented API call
262
            logger.Warn(ctx, "Undocumented API call attempted", map[string]interface{}{
263
                "method":     method,
264
                "path":       path,
265
                "ip":         c.ClientIP(),
266
                "user_agent": c.Request.UserAgent(),
267
            })
268

269
            // Return 404 for undocumented endpoints
270
            c.JSON(http.StatusNotFound, gin.H{
271
                "error":   "Endpoint not found",
272
                "message": "The requested endpoint is not documented in the API specification",
273
            })
274
            c.Abort()
275
            return
276
        }
277

278
        // Endpoint is documented, continue
279
        span.SetAttributes(
280
            observability.AttributeTypeFilter("endpoint_documented"),
281
        )
282

283
        // Validate request body against schema for POST/PUT/PATCH requests
284
        if method == "POST" || method == "PUT" || method == "PATCH" {
285
            // Determine the request body schema name for this endpoint
286
            schemaName := schemaLoader.DetermineRequestSchemaFromPath(path, method)
287

288
            // Log the schema determination for debugging
289
            logger.Info(ctx, "Request validation schema determined", map[string]interface{}{
290
                "method":      method,
291
                "path":        path,
292
                "schema_name": schemaName,
293
            })
294

295
            // Log when no schema is found
296
            if schemaName == "" {
297
                logger.Warn(ctx, "No schema found for endpoint", map[string]interface{}{
298
                    "method": method,
299
                    "path":   path,
300
                })
301
            }
302

303
            // Restore the request body so handlers can read it
304
            body, err := c.GetRawData()
305
            if err == nil && len(body) > 0 {
306
                c.Request.Body = io.NopCloser(bytes.NewBuffer(body))
307
            }
308

309
            if schemaName != "" {
310
                // Read the request body without consuming it
311
                body, err := c.GetRawData()
312
                if err == nil && len(body) > 0 {
313
                    // Restore the request body so handlers can read it
314
                    c.Request.Body = io.NopCloser(bytes.NewBuffer(body))
315

316
                    // Log the raw request body for debugging
317
                    logger.Info(ctx, "Request body received", map[string]interface{}{
318
                        "method":      method,
319
                        "path":        path,
320
                        "schema_name": schemaName,
321
                        "body":        string(body),
322
                    })
323

324
                    // Parse the JSON
325
                    var requestData interface{}
326
                    if err := json.Unmarshal(body, &requestData); err == nil {
327
                        // Validate the request data against the schema
328
                        if err := schemaLoader.ValidateData(requestData, schemaName); err != nil {
329
                            // Log the validation error and the request data
330
                            logger.Error(ctx, "Request validation failed", err, map[string]interface{}{
331
                                "method":       method,
332
                                "path":         path,
333
                                "schema_name":  schemaName,
334
                                "error":        err.Error(),
335
                                "request_data": requestData,
336
                                "raw_body":     string(body),
337
                            })
338
                            // Add validation error details to tracing span
339
                            span.SetAttributes(
340
                                observability.AttributeTypeFilter("validation_failed"),
341
                                observability.AttributeSearch(path),
342
                                observability.AttributeTypeFilter(method),
343
                                observability.AttributeTypeFilter(schemaName),
344
                                observability.AttributeTypeFilter("validation_error:"+err.Error()),
345
                                observability.AttributeTypeFilter("request_data:"+fmt.Sprintf("%v", requestData)),
346
                                observability.AttributeTypeFilter("raw_body:"+string(body)),
347
                            )
348
                            // Print a concise summary to stdout for test debug
349
                            fmt.Printf("\n[VALIDATION ERROR] %v\n[REQUEST DATA] %v\n[RAW BODY] %s\n\n", err, requestData, string(body))
350
                            // Return 400 for invalid request data
351
                            c.JSON(http.StatusBadRequest, gin.H{
352
                                "error":   "Invalid request data",
353
                                "message": "Request data does not match the API specification",
354
                                "method":  method,
355
                                "path":    path,
356
                                "schema":  schemaName,
357
                                "details": err.Error(),
358
                            })
359
                            c.Abort()
360
                            return
361
                        }
362
                    }
363

364
                    // Restore the request body so handlers can read it
365
                    c.Request.Body = io.NopCloser(bytes.NewBuffer(body))
366
                }
367
            }
368
        }
369

370
        // Continue to the next handler
371
        c.Next()
372
    }
373
}
374


			
quizapp internal models
42.9%
Statements
36/84
auth_api_key.go
0.0%
0/4
models.go
100.0%
36/36
story.go
0.0%
0/43
word_of_the_day.go
0.0%
0/1
quizapp internal models word_of_the_day.go
0.0%
Statements
0/4
1
package models
2

3
import (
4
    "database/sql"
5
    "time"
6
)
7

8
// AuthAPIKey represents an API key for programmatic authentication
9
// This is separate from user_api_keys which stores AI provider API keys
10
type AuthAPIKey struct {
11
    ID              int          `json:"id"`
12
    UserID          int          `json:"user_id"`
13
    KeyName         string       `json:"key_name"`
14
    KeyHash         string       `json:"-"` // Never expose the hash
15
    KeyPrefix       string       `json:"key_prefix"`
16
    PermissionLevel string       `json:"permission_level"` // "readonly" or "full"
17
    LastUsedAt      sql.NullTime `json:"last_used_at"`
18
    CreatedAt       time.Time    `json:"created_at"`
19
    UpdatedAt       time.Time    `json:"updated_at"`
20
}
21

22
// PermissionLevel constants
23
const (
24
    PermissionLevelReadonly = "readonly"
25
    PermissionLevelFull     = "full"
26
)
27

28
// IsValidPermissionLevel checks if the permission level is valid
29
func IsValidPermissionLevel(level string) bool {
30
    return level == PermissionLevelReadonly || level == PermissionLevelFull
31
}
32

33
// CanPerformMethod checks if the permission level allows the given HTTP method
34
func (k *AuthAPIKey) CanPerformMethod(method string) bool {
35
    if k.PermissionLevel == PermissionLevelFull {
36
        return true
37
    }
38
    // Readonly keys can only perform GET and HEAD requests
39
    return method == "GET" || method == "HEAD"
40
}
41


			
quizapp internal models word_of_the_day.go
100.0%
Statements
36/36
1
// Package models defines data structures used throughout the quiz application.
2
package models
3

4
import (
5
    "database/sql"
6
    "encoding/json"
7
    "time"
8

9
    "quizapp/internal/api"
10
)
11

12
// User represents a user in the system
13
type User struct {
14
    ID                    int            `json:"id" yaml:"id"`
15
    Username              string         `json:"username" yaml:"username"`
16
    Email                 sql.NullString `json:"email" yaml:"email"`
17
    Timezone              sql.NullString `json:"timezone" yaml:"timezone"`
18
    PasswordHash          sql.NullString `json:"-" yaml:"-"` // Omit from JSON responses
19
    LastActive            sql.NullTime   `json:"last_active" yaml:"last_active"`
20
    PreferredLanguage     sql.NullString `json:"preferred_language" yaml:"preferred_language"`
21
    CurrentLevel          sql.NullString `json:"current_level" yaml:"current_level"`
22
    AIProvider            sql.NullString `json:"ai_provider" yaml:"ai_provider"`
23
    AIModel               sql.NullString `json:"ai_model" yaml:"ai_model"`
24
    AIEnabled             sql.NullBool   `json:"ai_enabled" yaml:"ai_enabled"`
25
    AIAPIKey              sql.NullString `json:"-" yaml:"ai_api_key"` // Omit from JSON responses
26
    WordOfDayEmailEnabled sql.NullBool   `json:"word_of_day_email_enabled" yaml:"word_of_day_email_enabled"`
27
    CreatedAt             time.Time      `json:"created_at" yaml:"created_at"`
28
    UpdatedAt             time.Time      `json:"updated_at" yaml:"updated_at"`
29
    Roles                 []Role         `json:"roles,omitempty" yaml:"roles,omitempty"`
30
}
31

32
// Role represents a role in the system
33
type Role struct {
34
    ID          int       `json:"id" yaml:"id"`
35
    Name        string    `json:"name" yaml:"name"`
36
    Description string    `json:"description" yaml:"description"`
37
    CreatedAt   time.Time `json:"created_at" yaml:"created_at"`
38
    UpdatedAt   time.Time `json:"updated_at" yaml:"updated_at"`
39
}
40

41
// UserRole represents the mapping between users and roles
42
type UserRole struct {
43
    ID        int       `json:"id" yaml:"id"`
44
    UserID    int       `json:"user_id" yaml:"user_id"`
45
    RoleID    int       `json:"role_id" yaml:"role_id"`
46
    CreatedAt time.Time `json:"created_at" yaml:"created_at"`
47
}
48

49
// Snippet represents a vocabulary snippet saved by a user
50
type Snippet struct {
51
    ID              int64     `json:"id" yaml:"id"`
52
    UserID          int64     `json:"user_id" yaml:"user_id"`
53
    OriginalText    string    `json:"original_text" yaml:"original_text"`
54
    TranslatedText  string    `json:"translated_text" yaml:"translated_text"`
55
    SourceLanguage  string    `json:"source_language" yaml:"source_language"`
56
    TargetLanguage  string    `json:"target_language" yaml:"target_language"`
57
    QuestionID      *int64    `json:"question_id" yaml:"question_id"`
58
    SectionID       *int64    `json:"section_id" yaml:"section_id"`
59
    StoryID         *int64    `json:"story_id" yaml:"story_id"`
60
    Context         *string   `json:"context" yaml:"context"`
61
    DifficultyLevel *string   `json:"difficulty_level" yaml:"difficulty_level"`
62
    CreatedAt       time.Time `json:"created_at" yaml:"created_at"`
63
    UpdatedAt       time.Time `json:"updated_at" yaml:"updated_at"`
64
}
65

66
// TranslationCache represents a cached translation result
67
type TranslationCache struct {
68
    ID             int       `json:"id" yaml:"id"`
69
    TextHash       string    `json:"text_hash" yaml:"text_hash"`
70
    OriginalText   string    `json:"original_text" yaml:"original_text"`
71
    SourceLanguage string    `json:"source_language" yaml:"source_language"`
72
    TargetLanguage string    `json:"target_language" yaml:"target_language"`
73
    TranslatedText string    `json:"translated_text" yaml:"translated_text"`
74
    CreatedAt      time.Time `json:"created_at" yaml:"created_at"`
75
    ExpiresAt      time.Time `json:"expires_at" yaml:"expires_at"`
76
}
77

78
// MarshalJSON customizes JSON marshaling for User to handle sql.NullString and sql.NullTime properly
79
14x
func (u User) MarshalJSON() (result0 []byte, err error) { // Create a struct with the desired JSON structure
80
14x
    return json.Marshal(&struct {
81
14x
        ID                int        `json:"id"`
82
14x
        Username          string     `json:"username"`
83
14x
        Email             *string    `json:"email"`
84
14x
        Timezone          *string    `json:"timezone"`
85
14x
        LastActive        *time.Time `json:"last_active"`
86
14x
        PreferredLanguage *string    `json:"preferred_language"`
87
14x
        CurrentLevel      *string    `json:"current_level"`
88
14x
        AIProvider        *string    `json:"ai_provider"`
89
14x
        AIModel           *string    `json:"ai_model"`
90
14x
        AIEnabled         *bool      `json:"ai_enabled"`
91
14x
        CreatedAt         time.Time  `json:"created_at"`
92
14x
        UpdatedAt         time.Time  `json:"updated_at"`
93
14x
        Roles             []Role     `json:"roles,omitempty"`
94
14x
    }{
95
14x
        ID:                u.ID,
96
14x
        Username:          u.Username,
97
14x
        Email:             nullStringToPointer(u.Email),
98
14x
        Timezone:          nullStringToPointer(u.Timezone),
99
14x
        LastActive:        nullTimeToPointer(u.LastActive),
100
14x
        PreferredLanguage: nullStringToPointer(u.PreferredLanguage),
101
14x
        CurrentLevel:      nullStringToPointer(u.CurrentLevel),
102
14x
        AIProvider:        nullStringToPointer(u.AIProvider),
103
14x
        AIModel:           nullStringToPointer(u.AIModel),
104
14x
        AIEnabled:         nullBoolToPointer(u.AIEnabled),
105
14x
        CreatedAt:         u.CreatedAt,
106
14x
        UpdatedAt:         u.UpdatedAt,
107
14x
        Roles:             u.Roles,
108
14x
    })
109
14x
}
110

111
// Helper functions for converting sql.Null types to pointers
112
98x
func nullStringToPointer(ns sql.NullString) *string {
113
98x
    if ns.Valid {
114
33x
        return &ns.String
115
33x
    }
116
65x
    return nil
117
}
118

119
36x
func nullTimeToPointer(nt sql.NullTime) *time.Time {
120
36x
    if nt.Valid {
121
16x
        return &nt.Time
122
16x
    }
123
20x
    return nil
124
}
125

126
21x
func nullBoolToPointer(nb sql.NullBool) *bool {
127
21x
    if nb.Valid {
128
9x
        return &nb.Bool
129
9x
    }
130
12x
    return nil
131
}
132

133
4x
func nullInt32ToPointer(ni sql.NullInt32) *int32 {
134
4x
    if ni.Valid {
135
2x
        return &ni.Int32
136
2x
    }
137
2x
    return nil
138
}
139

140
// UserAPIKey represents an API key for a specific provider for a user
141
type UserAPIKey struct {
142
    ID        int       `json:"id"`
143
    UserID    int       `json:"user_id"`
144
    Provider  string    `json:"provider"`
145
    APIKey    string    `json:"-"` // Omit from JSON responses for security
146
    CreatedAt time.Time `json:"created_at"`
147
    UpdatedAt time.Time `json:"updated_at"`
148
}
149

150
// Question represents a quiz question
151
type Question struct {
152
    ID              int                    `json:"id" yaml:"id"`
153
    Type            QuestionType           `json:"type" yaml:"type"`
154
    Language        string                 `json:"language" yaml:"language"`
155
    Level           string                 `json:"level" yaml:"level"`
156
    DifficultyScore float64                `json:"difficulty_score" yaml:"difficulty_score"`
157
    Content         map[string]interface{} `json:"content" yaml:"content"`
158
    CorrectAnswer   int                    `json:"correct_answer" yaml:"correct_answer"`
159
    Explanation     string                 `json:"explanation,omitempty" yaml:"explanation"`
160
    CreatedAt       time.Time              `json:"created_at" yaml:"created_at"`
161
    Status          QuestionStatus         `json:"status" yaml:"status"`
162
    // Test data field for specifying which users should have this question
163
    Users []string `json:"users,omitempty" yaml:"users,omitempty"`
164
    // Variety elements for question generation diversity
165
    TopicCategory      string `json:"topic_category,omitempty" yaml:"topic_category"`
166
    GrammarFocus       string `json:"grammar_focus,omitempty" yaml:"grammar_focus"`
167
    VocabularyDomain   string `json:"vocabulary_domain,omitempty" yaml:"vocabulary_domain"`
168
    Scenario           string `json:"scenario,omitempty" yaml:"scenario"`
169
    StyleModifier      string `json:"style_modifier,omitempty" yaml:"style_modifier"`
170
    DifficultyModifier string `json:"difficulty_modifier,omitempty" yaml:"difficulty_modifier"`
171
    TimeContext        string `json:"time_context,omitempty" yaml:"time_context"`
172
}
173

174
// UserQuestion represents the mapping between users and questions
175
type UserQuestion struct {
176
    ID         int       `json:"id"`
177
    UserID     int       `json:"user_id"`
178
    QuestionID int       `json:"question_id"`
179
    CreatedAt  time.Time `json:"created_at"`
180
}
181

182
// QuestionReport represents a report of a question by a user
183
type QuestionReport struct {
184
    ID               int       `json:"id"`
185
    QuestionID       int       `json:"question_id"`
186
    ReportedByUserID int       `json:"reported_by_user_id"`
187
    ReportReason     string    `json:"report_reason"`
188
    CreatedAt        time.Time `json:"created_at"`
189
}
190

191
// QuestionType represents the type of question
192
type QuestionType string
193

194
// QuestionStatus represents the status of a question
195
type QuestionStatus string
196

197
const (
198
    // QuestionStatusActive is for questions that are in active use
199
    QuestionStatusActive QuestionStatus = "active"
200
    // QuestionStatusReported is for questions that have been reported as incorrect
201
    QuestionStatusReported QuestionStatus = "reported"
202
)
203

204
// Question types supported by the system
205
const (
206
    // Vocabulary represents vocabulary in context questions
207
    Vocabulary QuestionType = "vocabulary"
208
    // FillInBlank represents fill-in-the-blank questions
209
    FillInBlank QuestionType = "fill_blank"
210
    // QuestionAnswer represents simple Q&A questions
211
    QuestionAnswer QuestionType = "qa"
212
    // ReadingComprehension represents reading comprehension questions
213
    ReadingComprehension QuestionType = "reading_comprehension"
214
)
215

216
// UserResponse represents a user's answer to a question
217
type UserResponse struct {
218
    ID              int           `json:"id" yaml:"id"`
219
    UserID          int           `json:"user_id" yaml:"user_id"`
220
    QuestionID      int           `json:"question_id" yaml:"question_id"`
221
    UserAnswerIndex int           `json:"user_answer_index" yaml:"user_answer_index"`
222
    IsCorrect       bool          `json:"is_correct" yaml:"is_correct"`
223
    ResponseTimeMs  int           `json:"response_time_ms" yaml:"response_time_ms"`
224
    ConfidenceLevel sql.NullInt32 `json:"confidence_level" yaml:"confidence_level"`
225
    CreatedAt       time.Time     `json:"created_at" yaml:"created_at"`
226
}
227

228
// MarshalJSON customizes JSON marshaling for UserResponse to handle sql.NullInt32 properly
229
2x
func (ur UserResponse) MarshalJSON() (result0 []byte, err error) {
230
2x
    return json.Marshal(&struct {
231
2x
        ID              int       `json:"id"`
232
2x
        UserID          int       `json:"user_id"`
233
2x
        QuestionID      int       `json:"question_id"`
234
2x
        UserAnswerIndex int       `json:"user_answer_index"`
235
2x
        IsCorrect       bool      `json:"is_correct"`
236
2x
        ResponseTimeMs  int       `json:"response_time_ms"`
237
2x
        ConfidenceLevel *int32    `json:"confidence_level"`
238
2x
        CreatedAt       time.Time `json:"created_at"`
239
2x
    }{
240
2x
        ID:              ur.ID,
241
2x
        UserID:          ur.UserID,
242
2x
        QuestionID:      ur.QuestionID,
243
2x
        UserAnswerIndex: ur.UserAnswerIndex,
244
2x
        IsCorrect:       ur.IsCorrect,
245
2x
        ResponseTimeMs:  ur.ResponseTimeMs,
246
2x
        ConfidenceLevel: nullInt32ToPointer(ur.ConfidenceLevel),
247
2x
        CreatedAt:       ur.CreatedAt,
248
2x
    })
249
2x
}
250

251
// PerformanceMetrics tracks user performance across different categories
252
type PerformanceMetrics struct {
253
    ID                    int       `json:"id"`
254
    UserID                int       `json:"user_id"`
255
    Topic                 string    `json:"topic"`
256
    Language              string    `json:"language"`
257
    Level                 string    `json:"level"`
258
    TotalAttempts         int       `json:"total_attempts"`
259
    CorrectAttempts       int       `json:"correct_attempts"`
260
    AverageResponseTimeMs float64   `json:"average_response_time_ms"`
261
    DifficultyAdjustment  float64   `json:"difficulty_adjustment"`
262
    LastUpdated           time.Time `json:"last_updated"`
263
}
264

265
// AccuracyRate calculates the accuracy percentage
266
5x
func (pm *PerformanceMetrics) AccuracyRate() float64 {
267
5x
    if pm.TotalAttempts == 0 {
268
1x
        return 0.0
269
1x
    }
270
4x
    return float64(pm.CorrectAttempts) / float64(pm.TotalAttempts) * 100
271
}
272

273
// QuestionRequest represents a request for a new question
274
type QuestionRequest struct {
275
    UserID       int          `json:"user_id"`
276
    Language     string       `json:"language"`
277
    Level        string       `json:"level"`
278
    QuestionType QuestionType `json:"question_type,omitempty"`
279
}
280

281
// AnswerRequest represents a user's answer submission
282
type AnswerRequest struct {
283
    QuestionID     int    `json:"question_id"`
284
    UserAnswer     string `json:"user_answer"`
285
    ResponseTimeMs int    `json:"response_time_ms"`
286
}
287

288
// AnswerResponse represents the response to an answer submission
289
type AnswerResponse struct {
290
    IsCorrect      bool   `json:"is_correct"`
291
    CorrectAnswer  string `json:"correct_answer"`
292
    UserAnswer     string `json:"user_answer"`
293
    Explanation    string `json:"explanation"`
294
    NextDifficulty string `json:"next_difficulty,omitempty"`
295
}
296

297
// GetCorrectAnswerText returns the text of the correct answer from the question content
298
22x
func (q *Question) GetCorrectAnswerText() string {
299
22x
    if optionsRaw, ok := q.Content["options"]; ok {
300
17x
        if options, ok := optionsRaw.([]interface{}); ok {
301
15x
            if q.CorrectAnswer >= 0 && q.CorrectAnswer < len(options) {
302
12x
                if optStr, ok := options[q.CorrectAnswer].(string); ok {
303
12x
                    return optStr
304
12x
                }
305
            }
306
        }
307
    }
308
10x
    return ""
309
}
310

311
// UserSettings represents user preference settings
312
type UserSettings struct {
313
    Language   string `json:"language" yaml:"language"`
314
    Level      string `json:"level" yaml:"level"`
315
    AIProvider string `json:"ai_provider" yaml:"ai_provider"`
316
    AIModel    string `json:"ai_model" yaml:"ai_model"`
317
    AIEnabled  bool   `json:"ai_enabled" yaml:"ai_enabled"`
318
    AIAPIKey   string `json:"api_key" yaml:"ai_api_key"`
319
}
320

321
// UserLearningPreferences represents user learning preferences and settings
322
type UserLearningPreferences struct {
323
    ID                        int      `json:"id" db:"id"`
324
    UserID                    int      `json:"user_id" db:"user_id"`
325
    PreferredLanguage         string   `json:"preferred_language" db:"preferred_language"`
326
    CurrentLevel              string   `json:"current_level" db:"current_level"`
327
    AIProvider                string   `json:"ai_provider" db:"ai_provider"`
328
    AIModel                   string   `json:"ai_model" db:"ai_model"`
329
    AIEnabled                 bool     `json:"ai_enabled" db:"ai_enabled"`
330
    AIAPIKey                  string   `json:"-" db:"ai_api_key"` // Omit from JSON for security
331
    DailyGoal                 int      `json:"daily_goal" db:"daily_goal"`
332
    WeeklyGoal                int      `json:"weekly_goal" db:"weekly_goal"`
333
    PreferredQuestionType     string   `json:"preferred_question_type" db:"preferred_question_type"`
334
    PreferredQuestionTypes    []string `json:"preferred_question_types" db:"preferred_question_types"`
335
    PreferredDifficultyLevel  string   `json:"preferred_difficulty_level" db:"preferred_difficulty_level"`
336
    PreferredTopics           []string `json:"preferred_topics" db:"preferred_topics"`
337
    PreferredQuestionCount    int      `json:"preferred_question_count" db:"preferred_question_count"`
338
    SpacedRepetitionEnabled   bool     `json:"spaced_repetition_enabled" db:"spaced_repetition_enabled"`
339
    AdaptiveDifficultyEnabled bool     `json:"adaptive_difficulty_enabled" db:"adaptive_difficulty_enabled"`
340
    FocusOnWeakAreas          bool     `json:"focus_on_weak_areas" db:"focus_on_weak_areas"`
341
    IncludeReviewQuestions    bool     `json:"include_review_questions" db:"include_review_questions"`
342
    FreshQuestionRatio        float64  `json:"fresh_question_ratio" db:"fresh_question_ratio"`
343
    KnownQuestionPenalty      float64  `json:"known_question_penalty" db:"known_question_penalty"`
344
    ReviewIntervalDays        int      `json:"review_interval_days" db:"review_interval_days"`
345
    WeakAreaBoost             float64  `json:"weak_area_boost" db:"weak_area_boost"`
346
    StudyTime                 string   `json:"study_time" db:"study_time"`
347
    DailyReminderEnabled      bool     `json:"daily_reminder_enabled" db:"daily_reminder_enabled"`
348
    // Preferred TTS voice (e.g., it-IT-IsabellaNeural)
349
    TTSVoice              string     `json:"tts_voice" db:"tts_voice"`
350
    LastDailyReminderSent *time.Time `json:"last_daily_reminder_sent" db:"last_daily_reminder_sent"`
351
    CreatedAt             time.Time  `json:"created_at" db:"created_at"`
352
    UpdatedAt             time.Time  `json:"updated_at" db:"updated_at"`
353
}
354

355
// UserProgress represents a user's overall progress
356
type UserProgress struct {
357
    CurrentLevel       string                         `json:"current_level"`
358
    TotalQuestions     int                            `json:"total_questions"`
359
    CorrectAnswers     int                            `json:"correct_answers"`
360
    AccuracyRate       float64                        `json:"accuracy_rate"`
361
    PerformanceByTopic map[string]*PerformanceMetrics `json:"performance_by_topic"`
362
    WeakAreas          []string                       `json:"weak_areas"`
363
    RecentActivity     []UserResponse                 `json:"recent_activity"`
364
    SuggestedLevel     string                         `json:"suggested_level,omitempty"`
365
}
366

367
// AIQuestionGenRequest represents a request to the AI service for question generation
368
type AIQuestionGenRequest struct {
369
    Language              string       `json:"language"`
370
    Level                 string       `json:"level"`
371
    QuestionType          QuestionType `json:"question_type"`
372
    Count                 int          `json:"count"`
373
    RecentQuestionHistory []string     `json:"-"` // Don't include in JSON, internal use
374
}
375

376
// AIChatRequest represents a request to the AI service for a new chat feature
377
type AIChatRequest struct {
378
    Language              string
379
    Level                 string
380
    QuestionType          QuestionType // Question type for context
381
    Question              string
382
    Options               []string
383
    Passage               string // For reading comprehension
384
    UserAnswer            string // Optional
385
    CorrectAnswer         string // Optional
386
    IsCorrect             *bool  // Optional
387
    UserMessage           string
388
    ConversationHistory   []ChatMessage `json:"conversation_history,omitempty"`
389
    RecentQuestionHistory []string      `json:"-"` // Don't include in JSON, internal use
390
}
391

392
// ChatMessage represents a single message in the chat conversation
393
type ChatMessage struct {
394
    Role    api.ChatMessageRole `json:"role"`    // "user" or "assistant"
395
    Content string              `json:"content"` // The message content
396
}
397

398
// AIExplanationRequest represents a request for an explanation of a wrong answer
399
type AIExplanationRequest struct {
400
    Question      string `json:"question"`
401
    UserAnswer    string `json:"user_answer"`
402
    CorrectAnswer string `json:"correct_answer"`
403
    Language      string `json:"language"`
404
    Level         string `json:"level"`
405
}
406

407
// MarshalContentToJSON serializes the question content to JSON string
408
12x
func (q *Question) MarshalContentToJSON() (result0 string, err error) {
409
12x
    // Clean up fields that should be at the top level, not in content
410
12x
    // Remove fields that are not allowed in QuestionContent according to OpenAPI schema
411
12x
    if q.Content != nil {
412
9x
        // Always remove correct_answer from content as it should be at top level
413
9x
        delete(q.Content, "correct_answer")
414
9x
        // Always remove explanation from content as it should be at top level
415
9x
        delete(q.Content, "explanation")
416
9x
    }
417

418
12x
    data, err := json.Marshal(q.Content)
419
12x
    return string(data), err
420
}
421

422
// UnmarshalContentFromJSON deserializes JSON string into question content
423
14x
func (q *Question) UnmarshalContentFromJSON(data string) error {
424
14x
    err := json.Unmarshal([]byte(data), &q.Content)
425
14x
    if err != nil {
426
1x
        return err
427
1x
    }
428

429
    // Clean up fields that should be at the top level, not in content
430
    // Remove fields that are not allowed in QuestionContent according to OpenAPI schema
431
12x
    if q.Content != nil {
432
9x
        // Always remove correct_answer from content as it should be at top level
433
9x
        delete(q.Content, "correct_answer")
434
9x
        // Always remove explanation from content as it should be at top level
435
9x
        delete(q.Content, "explanation")
436
9x
    }
437

438
12x
    return nil
439
}
440

441
// WorkerSettings represents worker configuration settings stored in database
442
type WorkerSettings struct {
443
    ID           int       `json:"id" db:"id"`
444
    SettingKey   string    `json:"setting_key" db:"setting_key"`
445
    SettingValue string    `json:"setting_value" db:"setting_value"`
446
    CreatedAt    time.Time `json:"created_at" db:"created_at"`
447
    UpdatedAt    time.Time `json:"updated_at" db:"updated_at"`
448
}
449

450
// WorkerStatus represents worker health and activity status
451
type WorkerStatus struct {
452
    ID                      int            `json:"id" db:"id"`
453
    WorkerInstance          string         `json:"worker_instance" db:"worker_instance"`
454
    IsRunning               bool           `json:"is_running" db:"is_running"`
455
    IsPaused                bool           `json:"is_paused" db:"is_paused"`
456
    CurrentActivity         sql.NullString `json:"current_activity" db:"current_activity"`
457
    LastHeartbeat           sql.NullTime   `json:"last_heartbeat" db:"last_heartbeat"`
458
    LastRunStart            sql.NullTime   `json:"last_run_start" db:"last_run_start"`
459
    LastRunEnd              sql.NullTime   `json:"last_run_end" db:"last_run_end"`
460
    LastRunFinish           sql.NullTime   `json:"last_run_finish" db:"last_run_finish"`
461
    LastRunError            sql.NullString `json:"last_run_error" db:"last_run_error"`
462
    TotalQuestionsProcessed int            `json:"total_questions_processed" db:"total_questions_processed"`
463
    TotalQuestionsGenerated int            `json:"total_questions_generated" db:"total_questions_generated"`
464
    TotalRuns               int            `json:"total_runs" db:"total_runs"`
465
    CreatedAt               time.Time      `json:"created_at" db:"created_at"`
466
    UpdatedAt               time.Time      `json:"updated_at" db:"updated_at"`
467
}
468

469
// MarshalJSON customizes JSON marshaling for WorkerStatus to handle sql.NullString and sql.NullTime properly
470
2x
func (ws WorkerStatus) MarshalJSON() (result0 []byte, err error) {
471
2x
    return json.Marshal(&struct {
472
2x
        ID                      int        `json:"id"`
473
2x
        WorkerInstance          string     `json:"worker_instance"`
474
2x
        IsRunning               bool       `json:"is_running"`
475
2x
        IsPaused                bool       `json:"is_paused"`
476
2x
        CurrentActivity         *string    `json:"current_activity"`
477
2x
        LastHeartbeat           *time.Time `json:"last_heartbeat"`
478
2x
        LastRunStart            *time.Time `json:"last_run_start"`
479
2x
        LastRunEnd              *time.Time `json:"last_run_end"`
480
2x
        LastRunFinish           *time.Time `json:"last_run_finish"`
481
2x
        LastRunError            *string    `json:"last_run_error"`
482
2x
        TotalQuestionsProcessed int        `json:"total_questions_processed"`
483
2x
        TotalQuestionsGenerated int        `json:"total_questions_generated"`
484
2x
        TotalRuns               int        `json:"total_runs"`
485
2x
        CreatedAt               time.Time  `json:"created_at"`
486
2x
        UpdatedAt               time.Time  `json:"updated_at"`
487
2x
    }{
488
2x
        ID:                      ws.ID,
489
2x
        WorkerInstance:          ws.WorkerInstance,
490
2x
        IsRunning:               ws.IsRunning,
491
2x
        IsPaused:                ws.IsPaused,
492
2x
        CurrentActivity:         nullStringToPointer(ws.CurrentActivity),
493
2x
        LastHeartbeat:           nullTimeToPointer(ws.LastHeartbeat),
494
2x
        LastRunStart:            nullTimeToPointer(ws.LastRunStart),
495
2x
        LastRunEnd:              nullTimeToPointer(ws.LastRunEnd),
496
2x
        LastRunFinish:           nullTimeToPointer(ws.LastRunFinish),
497
2x
        LastRunError:            nullStringToPointer(ws.LastRunError),
498
2x
        TotalQuestionsProcessed: ws.TotalQuestionsProcessed,
499
2x
        TotalQuestionsGenerated: ws.TotalQuestionsGenerated,
500
2x
        TotalRuns:               ws.TotalRuns,
501
2x
        CreatedAt:               ws.CreatedAt,
502
2x
        UpdatedAt:               ws.UpdatedAt,
503
2x
    })
504
2x
}
505


			
quizapp internal models word_of_the_day.go
0.0%
Statements
0/43
1
package models
2

3
import (
4
    "errors"
5
    "strings"
6
    "time"
7
)
8

9
// StoryStatus represents the status of a story
10
type StoryStatus string
11

12
// Story status constants
13
const (
14
    StoryStatusActive    StoryStatus = "active"    // StoryStatusActive represents an active story
15
    StoryStatusArchived  StoryStatus = "archived"  // StoryStatusArchived represents an archived story
16
    StoryStatusCompleted StoryStatus = "completed" // StoryStatusCompleted represents a completed story
17
)
18

19
// SectionLength represents the preferred length of story sections
20
type SectionLength string
21

22
// Section length constants
23
const (
24
    SectionLengthShort  SectionLength = "short"  // SectionLengthShort represents a short section length
25
    SectionLengthMedium SectionLength = "medium" // SectionLengthMedium represents a medium section length
26
    SectionLengthLong   SectionLength = "long"   // SectionLengthLong represents a long section length
27
)
28

29
// GeneratorType represents who generated a story section
30
type GeneratorType string
31

32
// Generator type constants
33
const (
34
    GeneratorTypeWorker GeneratorType = "worker" // GeneratorTypeWorker represents worker-generated sections
35
    GeneratorTypeUser   GeneratorType = "user"   // GeneratorTypeUser represents user-generated sections
36
)
37

38
// Story represents a user-created story with metadata
39
type Story struct {
40
    ID                     uint           `json:"id"`
41
    UserID                 uint           `json:"user_id"`
42
    Title                  string         `json:"title"`
43
    Language               string         `json:"language"`
44
    Subject                *string        `json:"subject"`
45
    AuthorStyle            *string        `json:"author_style"`
46
    TimePeriod             *string        `json:"time_period"`
47
    Genre                  *string        `json:"genre"`
48
    Tone                   *string        `json:"tone"`
49
    CharacterNames         *string        `json:"character_names"`
50
    CustomInstructions     *string        `json:"custom_instructions"`
51
    SectionLengthOverride  *SectionLength `json:"section_length_override,omitempty"`
52
    Status                 StoryStatus    `json:"status"`
53
    AutoGenerationPaused   bool           `json:"auto_generation_paused"`
54
    LastSectionGeneratedAt *time.Time     `json:"last_section_generated_at"`
55
    ExtraGenerationsToday  int            `json:"extra_generations_today"`
56
    CreatedAt              time.Time      `json:"created_at"`
57
    UpdatedAt              time.Time      `json:"updated_at"`
58

59
    // Relationships
60
    User     User           `json:"user,omitempty"`
61
    Sections []StorySection `json:"sections,omitempty"`
62
}
63

64
// GetSectionLengthOverride returns the section length override as a string, handling nil pointers
65
func (s *Story) GetSectionLengthOverride() string {
66
    if s.SectionLengthOverride == nil {
67
        return ""
68
    }
69
    return string(*s.SectionLengthOverride)
70
}
71

72
// StorySection represents an individual section of a story
73
type StorySection struct {
74
    ID             uint          `json:"id"`
75
    StoryID        uint          `json:"story_id"`
76
    SectionNumber  int           `json:"section_number"`
77
    Content        string        `json:"content"`
78
    LanguageLevel  string        `json:"language_level"`
79
    WordCount      int           `json:"word_count"`
80
    GeneratedBy    GeneratorType `json:"generated_by"`
81
    GeneratedAt    time.Time     `json:"generated_at"`
82
    GenerationDate time.Time     `json:"generation_date"`
83

84
    // Relationships
85
    Story     Story                  `json:"story,omitempty"`
86
    Questions []StorySectionQuestion `json:"questions,omitempty"`
87
}
88

89
// StorySectionQuestion represents a comprehension question for a story section
90
type StorySectionQuestion struct {
91
    ID                 uint      `json:"id"`
92
    SectionID          uint      `json:"section_id"`
93
    QuestionText       string    `json:"question_text"`
94
    Options            []string  `json:"options"`
95
    CorrectAnswerIndex int       `json:"correct_answer_index"`
96
    Explanation        *string   `json:"explanation"`
97
    CreatedAt          time.Time `json:"created_at"`
98

99
    // Relationships
100
    Section StorySection `json:"section,omitempty"`
101
}
102

103
// StoryWithSections represents a story with all its sections loaded
104
type StoryWithSections struct {
105
    Story
106
    Sections []StorySection `json:"sections"`
107
}
108

109
// StorySectionWithQuestions represents a section with all its questions loaded
110
type StorySectionWithQuestions struct {
111
    StorySection
112
    Questions []StorySectionQuestion `json:"questions"`
113
}
114

115
// CreateStoryRequest represents the request to create a new story
116
type CreateStoryRequest struct {
117
    Title                 string         `json:"title" validate:"required,min=1,max=200"`
118
    Subject               *string        `json:"subject" validate:"omitempty,max=500"`
119
    AuthorStyle           *string        `json:"author_style" validate:"omitempty,max=200"`
120
    TimePeriod            *string        `json:"time_period" validate:"omitempty,max=200"`
121
    Genre                 *string        `json:"genre" validate:"omitempty,max=100"`
122
    Tone                  *string        `json:"tone" validate:"omitempty,max=100"`
123
    CharacterNames        *string        `json:"character_names" validate:"omitempty,max=1000"`
124
    CustomInstructions    *string        `json:"custom_instructions" validate:"omitempty,max=2000"`
125
    SectionLengthOverride *SectionLength `json:"section_length_override" validate:"omitempty,oneof=short medium long"`
126
}
127

128
// StoryGenerationRequest represents the request for AI story generation
129
type StoryGenerationRequest struct {
130
    UserID             uint          `json:"-"`
131
    StoryID            uint          `json:"-"`
132
    Language           string        `json:"language"`
133
    Level              string        `json:"level"`
134
    Title              string        `json:"title"`
135
    Subject            *string       `json:"subject,omitempty"`
136
    AuthorStyle        *string       `json:"author_style,omitempty"`
137
    TimePeriod         *string       `json:"time_period,omitempty"`
138
    Genre              *string       `json:"genre,omitempty"`
139
    Tone               *string       `json:"tone,omitempty"`
140
    CharacterNames     *string       `json:"character_names,omitempty"`
141
    CustomInstructions *string       `json:"custom_instructions,omitempty"`
142
    SectionLength      SectionLength `json:"section_length"`
143
    PreviousSections   string        `json:"previous_sections"`
144
    IsFirstSection     bool          `json:"is_first_section"`
145
    TargetWords        int           `json:"target_words"`
146
    TargetSentences    int           `json:"target_sentences"`
147
}
148

149
// StoryQuestionsRequest represents the request for AI question generation
150
type StoryQuestionsRequest struct {
151
    UserID        uint   `json:"-"`
152
    SectionID     uint   `json:"-"`
153
    Language      string `json:"language"`
154
    Level         string `json:"level"`
155
    SectionText   string `json:"section_text"`
156
    QuestionCount int    `json:"question_count"`
157
}
158

159
// StorySectionQuestionData represents the structure returned by AI for questions
160
type StorySectionQuestionData struct {
161
    QuestionText       string   `json:"question_text"`
162
    Options            []string `json:"options"`
163
    CorrectAnswerIndex int      `json:"correct_answer_index"`
164
    Explanation        *string  `json:"explanation"`
165
}
166

167
// Validate validates the CreateStoryRequest
168
func (r *CreateStoryRequest) Validate() error {
169
    if r.Title == "" {
170
        return errors.New("title is required")
171
    }
172
    if len(r.Title) > 200 {
173
        return errors.New("title must be 200 characters or less")
174
    }
175
    if r.Subject != nil && len(*r.Subject) > 500 {
176
        return errors.New("subject must be 500 characters or less")
177
    }
178
    if r.AuthorStyle != nil && len(*r.AuthorStyle) > 200 {
179
        return errors.New("author style must be 200 characters or less")
180
    }
181
    if r.TimePeriod != nil && len(*r.TimePeriod) > 200 {
182
        return errors.New("time period must be 200 characters or less")
183
    }
184
    if r.Genre != nil && len(*r.Genre) > 100 {
185
        return errors.New("genre must be 100 characters or less")
186
    }
187
    if r.Tone != nil && len(*r.Tone) > 100 {
188
        return errors.New("tone must be 100 characters or less")
189
    }
190
    if r.CharacterNames != nil && len(*r.CharacterNames) > 1000 {
191
        return errors.New("character names must be 1000 characters or less")
192
    }
193
    if r.CustomInstructions != nil && len(*r.CustomInstructions) > 2000 {
194
        return errors.New("custom instructions must be 2000 characters or less")
195
    }
196
    if r.SectionLengthOverride != nil {
197
        switch *r.SectionLengthOverride {
198
        case SectionLengthShort, SectionLengthMedium, SectionLengthLong:
199
            // Valid
200
        default:
201
            return errors.New("section length override must be one of: short, medium, long")
202
        }
203
    }
204
    return nil
205
}
206

207
// SanitizeInput sanitizes user input for safe use in AI prompts
208
func SanitizeInput(input string) string {
209
    // Basic sanitization - remove control characters and trim whitespace
210
    // In a production system, you might want more sophisticated sanitization
211
    result := strings.TrimSpace(input)
212

213
    // Remove null bytes and control characters
214
    for i := 0; i < len(result); i++ {
215
        if result[i] < 32 && result[i] != 9 && result[i] != 10 && result[i] != 13 {
216
            result = result[:i] + result[i+1:]
217
            i--
218
        }
219
    }
220

221
    return result
222
}
223

224
// UserAIConfig holds per-user AI configuration
225
type UserAIConfig struct {
226
    Provider string
227
    Model    string
228
    APIKey   string
229
    Username string // For logging purposes
230
}
231

232
// StoryGenerationEligibilityResponse represents the result of checking if a story section can be generated
233
type StoryGenerationEligibilityResponse struct {
234
    CanGenerate bool   `json:"can_generate"`
235
    Reason      string `json:"reason,omitempty"`
236
    Story       *Story `json:"story,omitempty"` // Include story data when needed for additional checks
237
}
238

239
// GetSectionLengthTarget returns the target word count for a story section
240
func GetSectionLengthTarget(level string, lengthPref *SectionLength) int {
241
    // Map CEFR levels to generic proficiency levels for backward compatibility
242
    levelMapping := map[string]string{
243
        "A1": "beginner",
244
        "A2": "elementary",
245
        "B1": "intermediate",
246
        "B2": "upper_intermediate",
247
        "C1": "advanced",
248
        "C2": "proficient",
249
    }
250

251
    genericLevel := levelMapping[level]
252
    if genericLevel == "" {
253
        // If no mapping found, default to intermediate
254
        genericLevel = "intermediate"
255
    }
256

257
    // Default length targets by proficiency level (in words)
258
    lengthTargets := map[string]map[SectionLength]int{
259
        "beginner":           {SectionLengthShort: 50, SectionLengthMedium: 80, SectionLengthLong: 120},
260
        "elementary":         {SectionLengthShort: 80, SectionLengthMedium: 120, SectionLengthLong: 180},
261
        "intermediate":       {SectionLengthShort: 150, SectionLengthMedium: 220, SectionLengthLong: 300},
262
        "upper_intermediate": {SectionLengthShort: 250, SectionLengthMedium: 350, SectionLengthLong: 450},
263
        "advanced":           {SectionLengthShort: 350, SectionLengthMedium: 500, SectionLengthLong: 650},
264
        "proficient":         {SectionLengthShort: 500, SectionLengthMedium: 700, SectionLengthLong: 900},
265
    }
266

267
    levelTargets, exists := lengthTargets[genericLevel]
268
    if !exists {
269
        // Default to intermediate if level not found
270
        levelTargets = lengthTargets["intermediate"]
271
    }
272

273
    if lengthPref != nil {
274
        if target, exists := levelTargets[*lengthPref]; exists {
275
            return target
276
        }
277
    }
278

279
    // Default to medium length
280
    return levelTargets[SectionLengthMedium]
281
}
282


			
quizapp internal models word_of_the_day.go
0.0%
Statements
0/1
1
package models
2

3
import (
4
    "encoding/json"
5
    "time"
6
)
7

8
// WordSourceType represents the type of source for the word of the day
9
type WordSourceType string
10

11
const (
12
    // WordSourceVocabularyQuestion represents a word from a vocabulary question
13
    WordSourceVocabularyQuestion WordSourceType = "vocabulary_question"
14
    // WordSourceSnippet represents a word from a user snippet
15
    WordSourceSnippet WordSourceType = "snippet"
16
)
17

18
// WordOfTheDay represents a daily word assignment for a user
19
type WordOfTheDay struct {
20
    ID             int            `json:"id" db:"id"`
21
    UserID         int            `json:"user_id" db:"user_id"`
22
    AssignmentDate time.Time      `json:"assignment_date" db:"assignment_date"`
23
    SourceType     WordSourceType `json:"source_type" db:"source_type"`
24
    SourceID       int            `json:"source_id" db:"source_id"`
25
    CreatedAt      time.Time      `json:"created_at" db:"created_at"`
26
}
27

28
// WordOfTheDayWithContent represents a word of the day with full content details
29
type WordOfTheDayWithContent struct {
30
    WordOfTheDay
31
    // Question is populated when SourceType is WordSourceVocabularyQuestion
32
    Question *Question `json:"question,omitempty"`
33
    // Snippet is populated when SourceType is WordSourceSnippet
34
    Snippet *Snippet `json:"snippet,omitempty"`
35
}
36

37
// WordOfTheDayDisplay represents the simplified display format for word of the day
38
// This is used for API responses and contains the essential information
39
type WordOfTheDayDisplay struct {
40
    Date          time.Time      `json:"date"`
41
    Word          string         `json:"word"`
42
    Translation   string         `json:"translation"`
43
    Sentence      string         `json:"sentence"`
44
    SourceType    WordSourceType `json:"source_type"`
45
    SourceID      int            `json:"source_id"`
46
    Language      string         `json:"language"`
47
    Level         string         `json:"level,omitempty"`
48
    Context       string         `json:"context,omitempty"`
49
    Explanation   string         `json:"explanation,omitempty"`
50
    TopicCategory string         `json:"topic_category,omitempty"`
51
}
52

53
// MarshalJSON customizes JSON marshaling for WordOfTheDayDisplay to format the date field as YYYY-MM-DD
54
// This ensures compliance with OpenAPI date format (not date-time)
55
func (w WordOfTheDayDisplay) MarshalJSON() ([]byte, error) {
56
    return json.Marshal(&struct {
57
        Date          string         `json:"date"`
58
        Word          string         `json:"word"`
59
        Translation   string         `json:"translation"`
60
        Sentence      string         `json:"sentence"`
61
        SourceType    WordSourceType `json:"source_type"`
62
        SourceID      int            `json:"source_id"`
63
        Language      string         `json:"language"`
64
        Level         string         `json:"level,omitempty"`
65
        Context       string         `json:"context,omitempty"`
66
        Explanation   string         `json:"explanation,omitempty"`
67
        TopicCategory string         `json:"topic_category,omitempty"`
68
    }{
69
        Date:          w.Date.UTC().Format("2006-01-02"),
70
        Word:          w.Word,
71
        Translation:   w.Translation,
72
        Sentence:      w.Sentence,
73
        SourceType:    w.SourceType,
74
        SourceID:      w.SourceID,
75
        Language:      w.Language,
76
        Level:         w.Level,
77
        Context:       w.Context,
78
        Explanation:   w.Explanation,
79
        TopicCategory: w.TopicCategory,
80
    })
81
}
82


			
quizapp internal observability
51.6%
Statements
115/223
global_tracer.go
2.2%
1/45
logging.go
67.2%
43/64
metrics.go
54.2%
13/24
middleware.go
61.9%
26/42
setup.go
88.9%
16/18
span_helpers.go
0.0%
0/6
tracing.go
66.7%
16/24
quizapp internal observability tracing.go
2.2%
Statements
1/45
1
package observability
2

3
import (
4
    "context"
5
    "fmt"
6

7
    "quizapp/internal/models"
8

9
    "go.opentelemetry.io/otel"
10
    "go.opentelemetry.io/otel/attribute"
11
    "go.opentelemetry.io/otel/trace"
12
)
13

14
var globalTracer trace.Tracer
15

16
// InitGlobalTracer initializes the global tracer for the application.
17
1x
func InitGlobalTracer() {
18
1x
    globalTracer = otel.Tracer("quiz-app")
19
1x
}
20

21
// GetGlobalTracer returns the global tracer instance for the application.
22
func GetGlobalTracer() trace.Tracer {
23
    if globalTracer == nil {
24
        // Fallback to default tracer if not initialized
25
        globalTracer = otel.Tracer("quiz-app")
26
    }
27
    return globalTracer
28
}
29

30
// TraceFunction starts a new span with a descriptive name for the given service and function.
31
func TraceFunction(ctx context.Context, serviceName, functionName string, attributes ...attribute.KeyValue) (context.Context, trace.Span) {
32
    tracer := GetGlobalTracer()
33
    spanName := fmt.Sprintf("%s.%s", serviceName, functionName)
34
    return tracer.Start(ctx, spanName, trace.WithAttributes(attributes...))
35
}
36

37
// TraceFunctionWithErrorHandling starts a new span and automatically adds error attributes if the function panics or returns an error.
38
func TraceFunctionWithErrorHandling(ctx context.Context, serviceName, functionName string, fn func() error, attributes ...attribute.KeyValue) error {
39
    _, span := TraceFunction(ctx, serviceName, functionName, attributes...)
40
    defer func() {
41
        if err := recover(); err != nil {
42
            span.SetAttributes(
43
                attribute.Bool("error", true),
44
                attribute.String("error.type", "panic"),
45
                attribute.String("error.message", fmt.Sprintf("%v", err)),
46
            )
47
            span.End()
48
            panic(err) // re-panic
49
        }
50
    }()
51

52
    err := fn()
53
    if err != nil {
54
        span.SetAttributes(
55
            attribute.Bool("error", true),
56
            attribute.String("error.message", err.Error()),
57
        )
58
    }
59
    span.End()
60
    return err
61
}
62

63
// TraceSnippetFunction starts a new span for a snippet service function.
64
func TraceSnippetFunction(ctx context.Context, functionName string, attributes ...attribute.KeyValue) (context.Context, trace.Span) {
65
    return TraceFunction(ctx, "snippet", functionName, attributes...)
66
}
67

68
// TraceTranslationFunction starts a new span for a translation service function.
69
func TraceTranslationFunction(ctx context.Context, functionName string, attributes ...attribute.KeyValue) (context.Context, trace.Span) {
70
    return TraceFunction(ctx, "translation", functionName, attributes...)
71
}
72

73
// TraceAIFunction starts a new span for an AI service function.
74
func TraceAIFunction(ctx context.Context, functionName string, attributes ...attribute.KeyValue) (context.Context, trace.Span) {
75
    return TraceFunction(ctx, "ai", functionName, attributes...)
76
}
77

78
// TraceUserFunction starts a new span for a user service function.
79
func TraceUserFunction(ctx context.Context, functionName string, attributes ...attribute.KeyValue) (context.Context, trace.Span) {
80
    return TraceFunction(ctx, "user", functionName, attributes...)
81
}
82

83
// TraceQuestionFunction starts a new span for a question service function.
84
func TraceQuestionFunction(ctx context.Context, functionName string, attributes ...attribute.KeyValue) (context.Context, trace.Span) {
85
    return TraceFunction(ctx, "question", functionName, attributes...)
86
}
87

88
// TraceWorkerFunction starts a new span for a worker service function.
89
func TraceWorkerFunction(ctx context.Context, functionName string, attributes ...attribute.KeyValue) (context.Context, trace.Span) {
90
    return TraceFunction(ctx, "worker", functionName, attributes...)
91
}
92

93
// TraceLearningFunction starts a new span for a learning service function.
94
func TraceLearningFunction(ctx context.Context, functionName string, attributes ...attribute.KeyValue) (context.Context, trace.Span) {
95
    return TraceFunction(ctx, "learning", functionName, attributes...)
96
}
97

98
// TraceHandlerFunction starts a new span for a handler function.
99
func TraceHandlerFunction(ctx context.Context, functionName string, attributes ...attribute.KeyValue) (context.Context, trace.Span) {
100
    return TraceFunction(ctx, "handler", functionName, attributes...)
101
}
102

103
// TraceVarietyFunction starts a new span for a variety service function.
104
func TraceVarietyFunction(ctx context.Context, functionName string, attributes ...attribute.KeyValue) (context.Context, trace.Span) {
105
    return TraceFunction(ctx, "variety", functionName, attributes...)
106
}
107

108
// TraceOAuthFunction starts a new span for an OAuth service function.
109
func TraceOAuthFunction(ctx context.Context, functionName string, attributes ...attribute.KeyValue) (context.Context, trace.Span) {
110
    return TraceFunction(ctx, "oauth", functionName, attributes...)
111
}
112

113
// TraceUsageStatsFunction starts a new span for a usage stats service function.
114
func TraceUsageStatsFunction(ctx context.Context, functionName string, attributes ...attribute.KeyValue) (context.Context, trace.Span) {
115
    return TraceFunction(ctx, "usage_stats", functionName, attributes...)
116
}
117

118
// TraceCleanupFunction starts a new span for a cleanup service function.
119
func TraceCleanupFunction(ctx context.Context, functionName string, attributes ...attribute.KeyValue) (context.Context, trace.Span) {
120
    return TraceFunction(ctx, "cleanup", functionName, attributes...)
121
}
122

123
// TraceDatabaseFunction starts a new span for a database function.
124
func TraceDatabaseFunction(ctx context.Context, functionName string, attributes ...attribute.KeyValue) (context.Context, trace.Span) {
125
    return TraceFunction(ctx, "database", functionName, attributes...)
126
}
127

128
// AttributeQuestion returns a tracing attribute for a question's ID.
129
func AttributeQuestion(q *models.Question) attribute.KeyValue {
130
    return attribute.String("question.id", fmt.Sprintf("%d", q.ID))
131
}
132

133
// AttributeQuestionID returns a tracing attribute for a question ID.
134
func AttributeQuestionID(id int) attribute.KeyValue {
135
    return attribute.Int("question.id", id)
136
}
137

138
// AttributeUserID returns a tracing attribute for a user ID.
139
func AttributeUserID(id int) attribute.KeyValue {
140
    return attribute.Int("user.id", id)
141
}
142

143
// AttributeSnippetID returns a tracing attribute for a snippet ID.
144
func AttributeSnippetID(id int) attribute.KeyValue {
145
    return attribute.Int("snippet.id", id)
146
}
147

148
// AttributeLanguage returns a tracing attribute for a language.
149
func AttributeLanguage(lang string) attribute.KeyValue {
150
    return attribute.String("language", lang)
151
}
152

153
// AttributeGenerationType returns a tracing attribute for a generation type.
154
func AttributeGenerationType(generationType models.GeneratorType) attribute.KeyValue {
155
    return attribute.String("generation_type", string(generationType))
156
}
157

158
// AttributeLevel returns a tracing attribute for a level.
159
func AttributeLevel(level string) attribute.KeyValue {
160
    return attribute.String("level", level)
161
}
162

163
// AttributeQuestionType returns a tracing attribute for a question type.
164
func AttributeQuestionType(qType interface{}) attribute.KeyValue {
165
    return attribute.String("question.type", fmt.Sprintf("%v", qType))
166
}
167

168
// AttributeLimit returns a tracing attribute for a limit value.
169
func AttributeLimit(limit int) attribute.KeyValue {
170
    return attribute.Int("limit", limit)
171
}
172

173
// AttributePage returns a tracing attribute for a page value.
174
func AttributePage(page int) attribute.KeyValue {
175
    return attribute.Int("page", page)
176
}
177

178
// AttributePageSize returns a tracing attribute for a page size value.
179
func AttributePageSize(size int) attribute.KeyValue {
180
    return attribute.Int("page_size", size)
181
}
182

183
// AttributeSearch returns a tracing attribute for a search value.
184
func AttributeSearch(search string) attribute.KeyValue {
185
    return attribute.String("search", search)
186
}
187

188
// AttributeTypeFilter returns a tracing attribute for a type filter value.
189
func AttributeTypeFilter(typeFilter string) attribute.KeyValue {
190
    return attribute.String("type_filter", typeFilter)
191
}
192

193
// AttributeStatusFilter returns a tracing attribute for a status filter value.
194
func AttributeStatusFilter(statusFilter string) attribute.KeyValue {
195
    return attribute.String("status_filter", statusFilter)
196
}
197


			
quizapp internal observability tracing.go
67.2%
Statements
43/64
1
// Package observability provides OpenTelemetry tracing, metrics, and structured logging
2
// with trace correlation for the quiz application.
3
package observability
4

5
import (
6
    "context"
7
    "os"
8

9
    "quizapp/internal/config"
10

11
    "go.opentelemetry.io/contrib/bridges/otelzap"
12
    "go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc"
13
    "go.opentelemetry.io/otel/sdk/log"
14
    "go.opentelemetry.io/otel/sdk/resource"
15
    semconv "go.opentelemetry.io/otel/semconv/v1.21.0"
16
    "go.uber.org/zap"
17
    "go.uber.org/zap/zapcore"
18
)
19

20
// Logger wraps the zap logger with OpenTelemetry context support
21
type Logger struct {
22
    *zap.Logger
23
}
24

25
// NewLogger creates a new logger with OpenTelemetry context support and OTLP export
26
3x
func NewLogger(cfg *config.OpenTelemetryConfig) *Logger {
27
3x
    return NewLoggerWithLevel(cfg, zap.InfoLevel)
28
3x
}
29

30
// NewLoggerWithLevel creates a new logger with OpenTelemetry context support and OTLP export
31
3x
func NewLoggerWithLevel(cfg *config.OpenTelemetryConfig, level zapcore.Level) *Logger {
32
3x
    // If logging is disabled, return a no-op logger
33
3x
    if cfg == nil || !cfg.EnableLogging {
34
1x
        return &Logger{Logger: zap.NewNop()}
35
1x
    }
36

37
    // Create a basic zap logger for stdout
38
2x
    zapConfig := zap.NewProductionConfig()
39
2x
    zapConfig.Level = zap.NewAtomicLevelAt(level)
40
2x
    zapConfig.EncoderConfig.TimeKey = "timestamp"
41
2x
    zapConfig.EncoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder
42
2x
    zapConfig.EncoderConfig.StacktraceKey = "stacktrace"
43
2x

44
2x
    // Use development config if in development mode
45
2x
    if os.Getenv("ENV") == "development" {
46
        zapConfig = zap.NewDevelopmentConfig()
47
        zapConfig.Level = zap.NewAtomicLevelAt(level)
48
    }
49

50
2x
    zapLogger, err := zapConfig.Build()
51
2x
    if err != nil {
52
        // Fallback to a basic logger if config fails
53
        zapLogger = zap.NewExample()
54
    }
55

56
    // If OTLP logging is enabled, set up the OTLP exporter
57
2x
    if cfg.EnableLogging && cfg.Endpoint != "" {
58
1x
        // Log that we're attempting to set up OTLP export
59
1x
        zapLogger.Info("Setting up OTLP logging", zap.String("endpoint", cfg.Endpoint), zap.String("protocol", cfg.Protocol))
60
1x

61
1x
        // Create OTLP exporter with proper endpoint format
62
1x
        endpoint := cfg.Endpoint
63
1x

64
1x
        // Set up resource attributes
65
1x
        res, err := resource.New(context.Background(),
66
1x
            resource.WithAttributes(
67
1x
                semconv.ServiceName(cfg.ServiceName),
68
1x
                semconv.ServiceVersion(cfg.ServiceVersion),
69
1x
            ),
70
1x
        )
71
1x
        if err != nil {
72
            // Log the error but continue with stdout logging
73
            zapLogger.Error("Failed to create otel resource", zap.Error(err))
74
        } else {
75
1x
            exporter, err := otlploggrpc.New(context.Background(),
76
1x
                otlploggrpc.WithEndpoint(endpoint),
77
1x
                otlploggrpc.WithInsecure(),
78
1x
            )
79
1x
            if err != nil {
80
                // Log the error but continue with stdout logging
81
                zapLogger.Error("Failed to create OTLP exporter", zap.Error(err), zap.String("endpoint", endpoint))
82
            } else {
83
1x
                zapLogger.Info("Successfully created OTLP exporter", zap.String("endpoint", endpoint))
84
1x

85
1x
                // Create batch processor
86
1x
                processor := log.NewBatchProcessor(exporter)
87
1x

88
1x
                // Create logger provider with resource
89
1x
                provider := log.NewLoggerProvider(
90
1x
                    log.WithProcessor(processor),
91
1x
                    log.WithResource(res),
92
1x
                )
93
1x

94
1x
                // Create OpenTelemetry core
95
1x
                otelCore := otelzap.NewCore("quizapp", otelzap.WithLoggerProvider(provider))
96
1x

97
1x
                // Create a new zap logger with both stdout and OTLP cores
98
1x
                cores := []zapcore.Core{
99
1x
                    zapLogger.Core(),
100
1x
                    otelCore,
101
1x
                }
102
1x

103
1x
                // Create a new logger with multiple cores
104
1x
                multiCore := zapcore.NewTee(cores...)
105
1x
                zapLogger = zap.New(multiCore)
106
1x

107
1x
                zapLogger.Info("OTLP logging successfully configured", zap.String("endpoint", endpoint))
108
1x
            }
109
        }
110
1x
    } else {
111
1x
        zapLogger.Info("OTLP logging not enabled", zap.Bool("enable_logging", cfg.EnableLogging), zap.String("endpoint", cfg.Endpoint))
112
1x
    }
113

114
2x
    return &Logger{Logger: zapLogger}
115
}
116

117
// Debug logs a debug message with context
118
func (l *Logger) Debug(ctx context.Context, msg string, fields ...map[string]interface{}) {
119
    l.logWithContext(ctx, zap.DebugLevel, msg, fields...)
120
}
121

122
// Info logs an info message with context
123
2x
func (l *Logger) Info(ctx context.Context, msg string, fields ...map[string]interface{}) {
124
2x
    l.logWithContext(ctx, zap.InfoLevel, msg, fields...)
125
2x
}
126

127
// Warn logs a warning message with context
128
func (l *Logger) Warn(ctx context.Context, msg string, fields ...map[string]interface{}) {
129
    l.logWithContext(ctx, zap.WarnLevel, msg, fields...)
130
}
131

132
// Error logs an error message with context
133
1x
func (l *Logger) Error(ctx context.Context, msg string, err error, fields ...map[string]interface{}) {
134
1x
    // Merge fields with error information
135
1x
    allFields := l.mergeFields(fields...)
136
1x
    if err != nil {
137
        allFields["error"] = err.Error()
138
    }
139
1x
    l.logWithContext(ctx, zap.ErrorLevel, msg, allFields)
140
}
141

142
// logWithContext logs a message with OpenTelemetry context correlation
143
3x
func (l *Logger) logWithContext(_ context.Context, level zapcore.Level, msg string, fields ...map[string]interface{}) {
144
3x
    // Merge all fields into a single map
145
3x
    allFields := l.mergeFields(fields...)
146
3x

147
3x
    // Convert fields to zap fields
148
3x
    zapFields := make([]zap.Field, 0, len(allFields))
149
3x
    for k, v := range allFields {
150
        zapFields = append(zapFields, zap.Any(k, v))
151
    }
152

153
    // Log with the appropriate level
154
3x
    switch level {
155
    case zap.DebugLevel:
156
        l.Logger.Debug(msg, zapFields...)
157
2x
    case zap.InfoLevel:
158
2x
        l.Logger.Info(msg, zapFields...)
159
    case zap.WarnLevel:
160
        l.Logger.Warn(msg, zapFields...)
161
1x
    case zap.ErrorLevel:
162
1x
        l.Logger.Error(msg, zapFields...)
163
    default:
164
        l.Logger.Info(msg, zapFields...)
165
    }
166
}
167

168
// mergeFields merges multiple field maps into a single map
169
4x
func (l *Logger) mergeFields(fields ...map[string]interface{}) map[string]interface{} {
170
4x
    if len(fields) == 0 {
171
3x
        return map[string]interface{}{}
172
3x
    }
173

174
1x
    if len(fields) == 1 {
175
1x
        // Handle nil field map
176
1x
        if fields[0] == nil {
177
            return map[string]interface{}{}
178
        }
179
1x
        return fields[0]
180
    }
181

182
    // Merge multiple field maps
183
    merged := make(map[string]interface{})
184
    for _, fieldMap := range fields {
185
        // Skip nil field maps
186
        if fieldMap == nil {
187
            continue
188
        }
189
        for k, v := range fieldMap {
190
            merged[k] = v
191
        }
192
    }
193
    return merged
194
}
195

196
// Sync flushes any buffered log entries
197
func (l *Logger) Sync() error {
198
    return l.Logger.Sync()
199
}
200


			
quizapp internal observability tracing.go
54.2%
Statements
13/24
1
package observability
2

3
import (
4
    "context"
5

6
    "quizapp/internal/config"
7
    contextutils "quizapp/internal/utils"
8

9
    "go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc"
10
    "go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp"
11
    "go.opentelemetry.io/otel/sdk/metric"
12
    "go.opentelemetry.io/otel/sdk/resource"
13
    semconv "go.opentelemetry.io/otel/semconv/v1.21.0"
14
)
15

16
// InitMetrics initializes OpenTelemetry metrics
17
1x
func InitMetrics(cfg *config.OpenTelemetryConfig) (result0 *metric.MeterProvider, err error) {
18
1x
    ctx := context.Background()
19
1x

20
1x
    // Set up resource attributes
21
1x
    res, err := resource.New(ctx,
22
1x
        resource.WithAttributes(
23
1x
            semconv.ServiceName(cfg.ServiceName),
24
1x
            semconv.ServiceVersion(cfg.ServiceVersion),
25
1x
        ),
26
1x
    )
27
1x
    if err != nil {
28
        return nil, contextutils.WrapErrorf(contextutils.ErrInternalError, "failed to create otel resource: %w", err)
29
    }
30

31
    // Set up exporter
32
1x
    var exporter metric.Exporter
33
1x
    switch cfg.Protocol {
34
1x
    case "grpc":
35
1x
        // For gRPC, strip http:// prefix if present, otherwise use endpoint as-is
36
1x
        endpoint := cfg.Endpoint
37
1x
        exp, err := otlpmetricgrpc.New(ctx,
38
1x
            otlpmetricgrpc.WithEndpoint(endpoint),
39
1x
            func() otlpmetricgrpc.Option {
40
1x
                if cfg.Insecure {
41
1x
                    return otlpmetricgrpc.WithInsecure()
42
1x
                }
43
                return nil
44
            }(),
45
            otlpmetricgrpc.WithHeaders(cfg.Headers),
46
        )
47
1x
        if err != nil {
48
            return nil, contextutils.WrapErrorf(contextutils.ErrInternalError, "failed to create otlp grpc metric exporter: %w", err)
49
        }
50
1x
        exporter = exp
51
    case "http":
52
        exp, err := otlpmetrichttp.New(ctx,
53
            otlpmetrichttp.WithEndpoint(cfg.Endpoint),
54
            func() otlpmetrichttp.Option {
55
                if cfg.Insecure {
56
                    return otlpmetrichttp.WithInsecure()
57
                }
58
                return nil
59
            }(),
60
            otlpmetrichttp.WithHeaders(cfg.Headers),
61
        )
62
        if err != nil {
63
            return nil, contextutils.WrapErrorf(contextutils.ErrInternalError, "failed to create otlp http metric exporter: %w", err)
64
        }
65
        exporter = exp
66
    default:
67
        return nil, contextutils.WrapErrorf(contextutils.ErrInternalError, "unsupported otel protocol: %s", cfg.Protocol)
68
    }
69

70
    // Set up meter provider
71
1x
    mp := metric.NewMeterProvider(
72
1x
        metric.WithReader(metric.NewPeriodicReader(exporter)),
73
1x
        metric.WithResource(res),
74
1x
    )
75
1x
    return mp, nil
76
}
77


			
quizapp internal observability tracing.go
61.9%
Statements
26/42
1
package observability
2

3
import (
4
    "errors"
5

6
    "github.com/gin-contrib/sessions"
7
    "github.com/gin-gonic/gin"
8
    "go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin"
9
    "go.opentelemetry.io/otel/attribute"
10
    "go.opentelemetry.io/otel/codes"
11
    "go.opentelemetry.io/otel/trace"
12

13
    contextutils "quizapp/internal/utils"
14
)
15

16
// GinMiddleware creates OpenTelemetry middleware for Gin HTTP requests
17
3x
func GinMiddleware(serviceName string) gin.HandlerFunc {
18
3x
    return otelgin.Middleware(serviceName)
19
3x
}
20

21
// GinMiddlewareWithErrorHandling creates OpenTelemetry middleware with automatic error attribute addition and detailed logging
22
3x
func GinMiddlewareWithErrorHandling(serviceName string) gin.HandlerFunc {
23
3x
    return func(c *gin.Context) {
24
9x
        // Use the existing OpenTelemetry middleware
25
9x
        otelgin.Middleware(serviceName)(c)
26
9x

27
9x
        // After the request is processed, check for errors
28
9x
        c.Next()
29
9x

30
9x
        // Get the span from context and add error attributes for failed requests
31
9x
        if span := trace.SpanFromContext(c.Request.Context()); span != nil {
32
9x
            statusCode := c.Writer.Status()
33
9x
            if statusCode >= 400 {
34
6x
                // Determine error severity based on status code and error types
35
6x
                severity := determineErrorSeverity(statusCode, c.Errors)
36
6x

37
6x
                // Create a more descriptive error message based on status code
38
6x
                var errorMsg string
39
6x
                switch {
40
2x
                case statusCode >= 500:
41
2x
                    errorMsg = "server error"
42
4x
                case statusCode >= 400:
43
4x
                    errorMsg = "client error"
44
                default:
45
                    errorMsg = "request failed"
46
                }
47

48
                // Add error details from Gin's error context if available
49
6x
                if len(c.Errors) > 0 {
50
                    for _, err := range c.Errors {
51
                        if appErr, ok := err.Err.(*contextutils.AppError); ok {
52
                            errorMsg = appErr.Message
53
                            severity = string(appErr.Severity)
54
                            break
55
                        }
56
                        errorMsg = err.Error()
57
                    }
58
                }
59

60
                // Record the error with stack trace
61
6x
                span.RecordError(errors.New(errorMsg), trace.WithStackTrace(true))
62
6x
                span.SetStatus(codes.Error, errorMsg)
63
6x

64
6x
                // Add additional attributes for better debugging
65
6x
                span.SetAttributes(
66
6x
                    attribute.Int("http.status_code", statusCode),
67
6x
                    attribute.String("http.method", c.Request.Method),
68
6x
                    attribute.String("http.path", c.Request.URL.Path),
69
6x
                    attribute.String("error.handler", c.HandlerName()),
70
6x
                    attribute.String("error.severity", severity),
71
6x
                )
72
6x

73
6x
                // Add user context if available
74
6x
                session := sessions.Default(c)
75
6x
                if userID, ok := session.Get("user_id").(int); ok {
76
                    span.SetAttributes(attribute.Int("error.user_id", userID))
77
                }
78

79
                // Add request body size for debugging
80
6x
                if c.Request.ContentLength > 0 {
81
                    span.SetAttributes(attribute.Int64("error.request_size", c.Request.ContentLength))
82
                }
83

84
                // Add specific error attributes based on error types
85
6x
                if len(c.Errors) > 0 {
86
                    for _, err := range c.Errors {
87
                        if appErr, ok := err.Err.(*contextutils.AppError); ok {
88
                            span.SetAttributes(
89
                                attribute.String("error.code", string(appErr.Code)),
90
                                attribute.Bool("error.retryable", contextutils.IsRetryable(appErr)),
91
                            )
92
                            break
93
                        }
94
                    }
95
                }
96

97
                // Add server error specific attributes
98
6x
                if statusCode >= 500 {
99
2x
                    span.SetAttributes(
100
2x
                        attribute.Bool("error.server_error", true),
101
2x
                    )
102
2x
                }
103
            }
104
        }
105
    }
106
}
107

108
// determineErrorSeverity determines the severity level based on status code and error types
109
6x
func determineErrorSeverity(statusCode int, errors []*gin.Error) string {
110
6x
    // Check for AppError types first
111
6x
    for _, err := range errors {
112
        if appErr, ok := err.Err.(*contextutils.AppError); ok {
113
            return string(appErr.Severity)
114
        }
115
    }
116

117
    // Fallback to status code based severity
118
6x
    switch {
119
2x
    case statusCode >= 500:
120
2x
        return string(contextutils.SeverityError)
121
4x
    case statusCode >= 400:
122
4x
        return string(contextutils.SeverityWarn)
123
    default:
124
        return string(contextutils.SeverityInfo)
125
    }
126
}
127


			
quizapp internal observability tracing.go
88.9%
Statements
16/18
1
package observability
2

3
import (
4
    "quizapp/internal/config"
5

6
    "go.opentelemetry.io/otel/sdk/metric"
7
    "go.opentelemetry.io/otel/sdk/trace"
8
)
9

10
// SetupObservability initializes tracing, metrics, and logging for a service
11
2x
func SetupObservability(cfg *config.OpenTelemetryConfig, serviceName string) (result0 *trace.TracerProvider, result1 *metric.MeterProvider, result2 *Logger, err error) {
12
2x
    if serviceName != "" {
13
2x
        cfg.ServiceName = serviceName
14
2x
    }
15

16
2x
    var tp *trace.TracerProvider
17
2x
    var mp *metric.MeterProvider
18
2x
    var logger *Logger
19
2x

20
2x
    if cfg.EnableTracing {
21
1x
        tp, err = InitTracing(cfg)
22
1x
        if err != nil {
23
            return nil, nil, nil, err
24
        }
25
        // Initialize the global tracer
26
1x
        InitGlobalTracer()
27
    }
28

29
2x
    if cfg.EnableMetrics {
30
1x
        mp, err = InitMetrics(cfg)
31
1x
        if err != nil {
32
            return tp, nil, nil, err
33
        }
34
    }
35

36
2x
    if cfg.EnableLogging {
37
1x
        logger = NewLogger(cfg)
38
1x
    } else {
39
1x
        // Return a no-op logger when logging is disabled
40
1x
        logger = NewLogger(&config.OpenTelemetryConfig{EnableLogging: false})
41
1x
    }
42

43
2x
    return tp, mp, logger, nil
44
}
45


			
quizapp internal observability tracing.go
0.0%
Statements
0/6
1
package observability
2

3
import (
4
    "go.opentelemetry.io/otel/codes"
5
    "go.opentelemetry.io/otel/trace"
6
)
7

8
// FinishSpan ends a span and records any error pointed to by errPtr.
9
// Use with a named error return: `defer observability.FinishSpan(span, &err)`
10
func FinishSpan(span trace.Span, errPtr *error) {
11
    if span == nil {
12
        return
13
    }
14
    if errPtr != nil && *errPtr != nil {
15
        span.RecordError(*errPtr, trace.WithStackTrace(true))
16
        span.SetStatus(codes.Error, (*errPtr).Error())
17
    }
18
    span.End()
19
}
20


			
quizapp internal observability tracing.go
66.7%
Statements
16/24
1
package observability
2

3
import (
4
    "context"
5

6
    "quizapp/internal/config"
7
    contextutils "quizapp/internal/utils"
8

9
    "go.opentelemetry.io/otel"
10
    "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc"
11
    "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp"
12
    "go.opentelemetry.io/otel/propagation"
13
    "go.opentelemetry.io/otel/sdk/resource"
14
    "go.opentelemetry.io/otel/sdk/trace"
15
    semconv "go.opentelemetry.io/otel/semconv/v1.21.0"
16
)
17

18
// InitTracing initializes OpenTelemetry tracing
19
1x
func InitTracing(cfg *config.OpenTelemetryConfig) (result0 *trace.TracerProvider, err error) {
20
1x
    ctx := context.Background()
21
1x

22
1x
    // Set up resource attributes
23
1x
    res, err := resource.New(ctx,
24
1x
        resource.WithAttributes(
25
1x
            semconv.ServiceName(cfg.ServiceName),
26
1x
            semconv.ServiceVersion(cfg.ServiceVersion),
27
1x
        ),
28
1x
    )
29
1x
    if err != nil {
30
        return nil, contextutils.WrapErrorf(contextutils.ErrInternalError, "failed to create otel resource: %w", err)
31
    }
32

33
    // Set up exporter
34
1x
    var exporter trace.SpanExporter
35
1x
    switch cfg.Protocol {
36
1x
    case "grpc":
37
1x
        // For gRPC, strip http:// prefix if present, otherwise use endpoint as-is
38
1x
        endpoint := cfg.Endpoint
39
1x
        exp, err := otlptracegrpc.New(ctx,
40
1x
            otlptracegrpc.WithEndpoint(endpoint),
41
1x
            func() otlptracegrpc.Option {
42
1x
                if cfg.Insecure {
43
1x
                    return otlptracegrpc.WithInsecure()
44
1x
                }
45
                return nil
46
            }(),
47
            otlptracegrpc.WithHeaders(cfg.Headers),
48
        )
49
1x
        if err != nil {
50
            return nil, contextutils.WrapErrorf(contextutils.ErrInternalError, "failed to create otlp grpc exporter: %w", err)
51
        }
52
1x
        exporter = exp
53
    case "http":
54
        exp, err := otlptracehttp.New(ctx,
55
            otlptracehttp.WithEndpoint(cfg.Endpoint),
56
            otlptracehttp.WithInsecure(),
57
            otlptracehttp.WithHeaders(cfg.Headers),
58
        )
59
        if err != nil {
60
            return nil, contextutils.WrapErrorf(contextutils.ErrInternalError, "failed to create otlp http exporter: %w", err)
61
        }
62
        exporter = exp
63
    default:
64
        return nil, contextutils.WrapErrorf(contextutils.ErrInternalError, "unsupported otel protocol: %s", cfg.Protocol)
65
    }
66

67
    // Set up sampler
68
1x
    sampler := trace.ParentBased(trace.TraceIDRatioBased(cfg.SamplingRate))
69
1x

70
1x
    // Set up tracer provider
71
1x
    tp := trace.NewTracerProvider(
72
1x
        trace.WithBatcher(exporter),
73
1x
        trace.WithResource(res),
74
1x
        trace.WithSampler(sampler),
75
1x
    )
76
1x
    otel.SetTracerProvider(tp)
77
1x

78
1x
    // Set up text map propagator for trace context propagation
79
1x
    // This enables the backend to receive and process trace headers from NGINX
80
1x
    otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator(
81
1x
        propagation.TraceContext{},
82
1x
        propagation.Baggage{},
83
1x
    ))
84
1x

85
1x
    return tp, nil
86
}
87


			
quizapp internal services
58.0%
Statements
4554/7854
ai_service.go
59.4%
574/967
ai_service_templates.go
78.6%
11/14
auth_api_key_service.go
80.5%
132/164
cleanup_service.go
83.5%
86/103
conversation_service.go
45.8%
125/273
daily_question_service.go
62.2%
265/426
email_factory.go
90.9%
10/11
email_service.go
29.4%
35/119
feedback_service.go
0.0%
0/126
generation_hint_service.go
83.3%
25/30
learning_service.go
75.7%
577/762
linear_service.go
81.8%
260/318
no_questions_error.go
50.0%
1/2
oauth_service.go
62.2%
84/135
question_service.go
72.6%
884/1218
snippets_service.go
64.3%
232/361
story_service.go
38.5%
255/663
test_email_service.go
63.9%
23/36
test_utils.go
66.7%
26/39
translation_cache_repository.go
50.0%
32/64
translation_service.go
22.1%
23/104
usage_stats_service.go
18.5%
33/178
user_service.go
62.3%
484/777
variety_service.go
83.2%
89/107
word_of_the_day_service.go
43.7%
93/213
worker_service.go
30.3%
195/644
quizapp internal services worker_service.go
59.4%
Statements
574/967
1
// Package services provides business logic services for the quiz application.
2
package services
3

4
import (
5
    "bufio"
6
    "bytes"
7
    "context"
8
    "encoding/json"
9
    "fmt"
10
    "io"
11
    "net/http"
12
    "runtime/debug"
13
    "strconv"
14
    "strings"
15
    "sync"
16
    "time"
17

18
    "quizapp/internal/config"
19
    "quizapp/internal/models"
20
    "quizapp/internal/observability"
21
    contextutils "quizapp/internal/utils"
22

23
    "github.com/xeipuuv/gojsonschema"
24
    "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
25
    "go.opentelemetry.io/otel/attribute"
26
    "go.opentelemetry.io/otel/codes"
27
    "go.opentelemetry.io/otel/trace"
28
)
29

30
// JSON Schema definitions for grammar field
31
// These schemas are used with the 'grammar' field in OpenAI-compatible API requests
32
// to enforce specific JSON structure validation. This ensures that AI models return
33
// exactly the expected format, eliminating parsing errors and improving reliability.
34
//
35
// The grammar field is conditionally included based on provider support (see supportsGrammarField).
36
// Providers that don't support grammar (like Google) will fall back to prompt-based structure guidance.
37
const (
38
    // Single-item schemas for ai-fix (single question objects)
39
    SingleQuestionSchema = `{
40
        "type": "object",
41
        "properties": {
42
            "question": {"type": "string"},
43
            "options": {"type": "array", "items": {"type": "string"}, "minItems": 4, "maxItems": 4},
44
            "correct_answer": {"type": "integer"},
45
            "explanation": {"type": "string"},
46
            "topic": {"type": "string"}
47
        },
48
        "required": ["question", "options", "correct_answer", "explanation"]
49
    }`
50

51
    SingleReadingComprehensionSchema = `{
52
        "type": "object",
53
        "properties": {
54
            "passage": {"type": "string"},
55
            "question": {"type": "string"},
56
            "options": {"type": "array", "items": {"type": "string"}, "minItems": 4, "maxItems": 4},
57
            "correct_answer": {"type": "integer"},
58
            "explanation": {"type": "string"},
59
            "topic": {"type": "string"}
60
        },
61
        "required": ["passage", "question", "options", "correct_answer", "explanation"]
62
    }`
63

64
    SingleVocabularyQuestionSchema = `{
65
        "type": "object",
66
        "properties": {
67
            "sentence": {"type": "string"},
68
            "question": {"type": "string"},
69
            "options": {"type": "array", "items": {"type": "string"}, "minItems": 4, "maxItems": 4},
70
            "correct_answer": {"type": "integer"},
71
            "explanation": {"type": "string"},
72
            "topic": {"type": "string"}
73
        },
74
        "required": ["sentence", "question", "options", "correct_answer", "explanation"]
75
    }`
76
)
77

78
var (
79
    // BatchQuestionsSchema is a batch wrapper around SingleQuestionSchema.
80
    BatchQuestionsSchema = fmt.Sprintf(`{"type":"array","items":%s}`, SingleQuestionSchema)
81

82
    // BatchReadingComprehensionSchema is a batch wrapper around SingleReadingComprehensionSchema.
83
    BatchReadingComprehensionSchema = fmt.Sprintf(`{"type":"array","items":%s}`, SingleReadingComprehensionSchema)
84

85
    // BatchVocabularyQuestionSchema is a batch wrapper around SingleVocabularyQuestionSchema.
86
    BatchVocabularyQuestionSchema = fmt.Sprintf(`{"type":"array","items":%s}`, SingleVocabularyQuestionSchema)
87
)
88

89
// UserAIConfig holds per-user AI configuration
90
type UserAIConfig struct {
91
    Provider string
92
    Model    string
93
    APIKey   string
94
    Username string // For logging purposes
95
}
96

97
// AIServiceInterface defines the interface for AI-powered question generation
98
type AIServiceInterface interface {
99
    GenerateQuestion(ctx context.Context, userConfig *models.UserAIConfig, req *models.AIQuestionGenRequest) (*models.Question, error)
100
    GenerateQuestions(ctx context.Context, userConfig *models.UserAIConfig, req *models.AIQuestionGenRequest) ([]*models.Question, error)
101
    GenerateQuestionsStream(ctx context.Context, userConfig *models.UserAIConfig, req *models.AIQuestionGenRequest, progress chan<- *models.Question, variety *VarietyElements) error
102
    GenerateChatResponse(ctx context.Context, userConfig *models.UserAIConfig, req *models.AIChatRequest) (string, error)
103
    GenerateChatResponseStream(ctx context.Context, userConfig *models.UserAIConfig, req *models.AIChatRequest, chunks chan<- string) error
104
    GenerateStorySection(ctx context.Context, userConfig *models.UserAIConfig, req *models.StoryGenerationRequest) (string, error)
105
    GenerateStoryQuestions(ctx context.Context, userConfig *models.UserAIConfig, req *models.StoryQuestionsRequest) ([]*models.StorySectionQuestionData, error)
106
    TestConnection(ctx context.Context, provider, model, apiKey string) error
107
    GetConcurrencyStats() ConcurrencyStats
108
    GetQuestionBatchSize(provider string) int
109
    VarietyService() *VarietyService
110

111
    // TemplateManager exposes template rendering and example loading for prompts
112
    TemplateManager() *AITemplateManager
113

114
    // SupportsGrammarField reports whether the provider supports the grammar field
115
    SupportsGrammarField(provider string) bool
116

117
    // CallWithPrompt sends a raw prompt (and optional grammar) to the provider and returns the response
118
    CallWithPrompt(ctx context.Context, userConfig *models.UserAIConfig, prompt, grammar string) (string, error)
119
    Shutdown(ctx context.Context) error
120
}
121

122
// ConcurrencyStats provides metrics about AI request concurrency
123
type ConcurrencyStats struct {
124
    ActiveRequests  int            `json:"active_requests"`
125
    MaxConcurrent   int            `json:"max_concurrent"`
126
    QueuedRequests  int            `json:"queued_requests"`
127
    TotalRequests   int64          `json:"total_requests"`
128
    UserActiveCount map[string]int `json:"user_active_count"`
129
    MaxPerUser      int            `json:"max_per_user"`
130
}
131

132
// AIService provides AI-powered question generation using OpenAI-compatible APIs
133
type AIService struct {
134
    httpClient *http.Client
135
    debug      bool
136
    cfg        *config.Config
137

138
    // Template management
139
    templateManager *AITemplateManager
140

141
    // Variety service for question diversity
142
    varietyService *VarietyService
143

144
    // Usage stats service for tracking token usage
145
    usageStatsSvc UsageStatsServiceInterface
146

147
    // Concurrency control
148
    globalSemaphore chan struct{} // Limits total concurrent requests
149
    maxConcurrent   int           // Maximum concurrent requests globally
150
    maxPerUser      int           // Maximum concurrent requests per user
151

152
    // Per-user concurrency tracking
153
    userRequestCount map[string]int // Username -> active request count
154
    concurrencyMu    sync.RWMutex   // Protects user maps
155

156
    // Metrics
157
    totalRequests  int64        // Total requests processed
158
    activeRequests int          // Current active requests
159
    statsMu        sync.RWMutex // Protects stats
160

161
    // Observability
162
    logger *observability.Logger
163

164
    // Shutdown control
165
    shutdownCtx context.Context
166
    shutdownMu  sync.RWMutex
167
}
168

169
// Schema validation counters
170
var (
171
    SchemaValidationFailures       = make(map[models.QuestionType]int)
172
    SchemaValidationFailureDetails = make(map[models.QuestionType][]string) // NEW: error details
173
    SchemaValidationMu             sync.Mutex
174
)
175

176
// extractItemsSchema extracts the items schema from a batch schema
177
2021x
func extractItemsSchema(batchSchema string) (result0 string, err error) {
178
2021x
    var schemaMap map[string]interface{}
179
2021x
    if err = json.Unmarshal([]byte(batchSchema), &schemaMap); err != nil {
180
        return "", err
181
    }
182
    // For batch schemas, extract the items schema
183
2021x
    if items, ok := schemaMap["items"]; ok {
184
2021x
        var itemsBytes []byte
185
2021x
        itemsBytes, err = json.Marshal(items)
186
2021x
        if err != nil {
187
            return "", err
188
        }
189
2021x
        return string(itemsBytes), nil
190
    }
191
    return "", contextutils.ErrorWithContextf("no items found in batch schema")
192
}
193

194
// ValidateQuestionSchema validates a question against the appropriate schema
195
2013x
func (s *AIService) ValidateQuestionSchema(ctx context.Context, qType models.QuestionType, question interface{}) (result0 bool, err error) {
196
2013x
    _, span := observability.TraceAIFunction(ctx, "validate_question_schema",
197
2013x
        observability.AttributeQuestionType(qType),
198
2013x
    )
199
2013x
    defer observability.FinishSpan(span, &err)
200
2013x

201
2013x
    // Validate input parameters
202
2013x
    if question == nil {
203
        span.SetAttributes(attribute.String("validation.result", "nil_question"))
204
        return false, contextutils.ErrorWithContextf("question cannot be nil")
205
    }
206

207
2013x
    var schema string
208
2013x
    switch qType {
209
2011x
    case models.Vocabulary:
210
2011x
        schema = BatchVocabularyQuestionSchema
211
1x
    case models.ReadingComprehension:
212
1x
        schema = BatchReadingComprehensionSchema
213
    case models.FillInBlank, models.QuestionAnswer:
214
        schema = BatchQuestionsSchema
215
    default:
216
        span.SetAttributes(attribute.String("validation.result", "unknown_type"))
217
        return false, contextutils.ErrorWithContextf("unknown question type: %v", qType)
218
    }
219

220
    // Extract the items schema for validation
221
2013x
    itemSchema, err := extractItemsSchema(schema)
222
2013x
    if err != nil {
223
        span.SetAttributes(attribute.String("validation.result", "schema_extract_error"), attribute.String("validation.error", err.Error()))
224
        return false, contextutils.WrapErrorf(err, "failed to extract schema for question type %v", qType)
225
    }
226

227
    // Marshal the question to JSON
228
    // If question is a *models.Question, validate only Content
229
2013x
    toValidate := question
230
2013x
    if q, ok := question.(*models.Question); ok {
231
2013x
        if q == nil {
232
            span.SetAttributes(attribute.String("validation.result", "nil_question_model"))
233
            return false, contextutils.ErrorWithContextf("question model is nil")
234
        }
235
2013x
        toValidate = q.Content
236
    }
237

238
2013x
    questionBytes, err := json.Marshal(toValidate)
239
2013x
    if err != nil {
240
        span.SetAttributes(attribute.String("validation.result", "marshal_error"), attribute.String("validation.error", err.Error()))
241
        return false, contextutils.WrapErrorf(err, "failed to marshal question for validation")
242
    }
243

244
    // Validate
245
2013x
    result, err := gojsonschema.Validate(
246
2013x
        gojsonschema.NewStringLoader(itemSchema),
247
2013x
        gojsonschema.NewBytesLoader(questionBytes),
248
2013x
    )
249
2013x
    if err != nil {
250
        span.SetAttributes(attribute.String("validation.result", "validate_error"), attribute.String("validation.error", err.Error()))
251
        return false, contextutils.WrapErrorf(err, "schema validation failed for question type %v", qType)
252
    }
253

254
2013x
    if !result.Valid() {
255
        errs := result.Errors()
256
        var errorMessages []string
257
        for _, e := range errs {
258
            errorMessages = append(errorMessages, e.String())
259
        }
260
        span.SetAttributes(attribute.String("validation.result", "invalid"))
261
        return false, contextutils.ErrorWithContextf("question failed schema validation: %s", strings.Join(errorMessages, "; "))
262
    }
263

264
2013x
    span.SetAttributes(attribute.String("validation.result", "valid"))
265
2013x
    return true, nil
266
}
267

268
// NewAIService creates a new AI service instance
269
60x
func NewAIService(cfg *config.Config, logger *observability.Logger, usageStatsSvc UsageStatsServiceInterface) *AIService {
270
60x
    // Validate required dependencies
271
60x
    if usageStatsSvc == nil {
272
        panic("usageStatsSvc is required for AI service")
273
    }
274

275
    // Create template manager
276
60x
    templateManager, err := NewAITemplateManager()
277
60x
    if err != nil {
278
        logger.Error(context.Background(), "Failed to create template manager", err, map[string]interface{}{})
279
        panic(err) // Use panic for fatal errors in initialization
280
    }
281

282
    // Create variety service
283
60x
    varietyService := NewVarietyServiceWithLogger(cfg, logger)
284
60x

285
60x
    // Create instrumented HTTP client with reasonable timeouts and explicit span options
286
60x
    // Use a timeout slightly less than AIRequestTimeout to allow context cancellation
287
60x
    httpClient := &http.Client{
288
60x
        Timeout: config.AIRequestTimeout - 5*time.Second, // Slightly less than AIRequestTimeout
289
60x
        Transport: otelhttp.NewTransport(http.DefaultTransport,
290
60x
            otelhttp.WithSpanOptions(trace.WithSpanKind(trace.SpanKindClient)),
291
60x
        ),
292
60x
    }
293
60x

294
60x
    // Get concurrency limits from config
295
60x
    maxConcurrent := cfg.Server.MaxAIConcurrent
296
60x
    maxPerUser := cfg.Server.MaxAIPerUser
297
60x

298
60x
    // Create global semaphore for limiting concurrent requests
299
60x
    globalSemaphore := make(chan struct{}, maxConcurrent)
300
60x

301
60x
    service := &AIService{
302
60x
        httpClient:       httpClient,
303
60x
        debug:            cfg.Server.Debug,
304
60x
        cfg:              cfg,
305
60x
        templateManager:  templateManager,
306
60x
        varietyService:   varietyService,
307
60x
        usageStatsSvc:    usageStatsSvc,
308
60x
        globalSemaphore:  globalSemaphore,
309
60x
        maxConcurrent:    maxConcurrent,
310
60x
        maxPerUser:       maxPerUser,
311
60x
        userRequestCount: make(map[string]int),
312
60x
        shutdownCtx:      context.Background(),
313
60x
        logger:           logger,
314
60x
    }
315
60x

316
60x
    return service
317
}
318

319
// Shutdown gracefully shuts down the AI service and cleans up resources
320
6x
func (s *AIService) Shutdown(ctx context.Context) error {
321
6x
    s.shutdownMu.Lock()
322
6x
    defer s.shutdownMu.Unlock()
323
6x

324
6x
    // Create a new shutdown context
325
6x
    shutdownCtx, cancel := context.WithCancel(ctx)
326
6x
    s.shutdownCtx = shutdownCtx
327
6x
    defer cancel()
328
6x

329
6x
    // Wait for all active requests to complete with timeout
330
6x
    timeout := config.AIShutdownTimeout
331
6x
    if deadline, ok := ctx.Deadline(); ok {
332
1x
        timeout = time.Until(deadline)
333
1x
    }
334

335
    // Wait for active requests to complete
336
6x
    ticker := time.NewTicker(config.AIShutdownPollInterval)
337
6x
    defer ticker.Stop()
338
6x

339
6x
    for i := 0; i < int(timeout/config.AIShutdownPollInterval); i++ {
340
6x
        s.statsMu.RLock()
341
6x
        active := s.activeRequests
342
6x
        s.statsMu.RUnlock()
343
6x

344
6x
        if active == 0 {
345
6x
            break
346
        }
347

348
        select {
349
        case <-ticker.C:
350
            continue
351
        case <-ctx.Done():
352
            return ctx.Err()
353
        }
354
    }
355

356
    // Close the HTTP client
357
6x
    if s.httpClient != nil {
358
6x
        s.httpClient.CloseIdleConnections()
359
6x
    }
360

361
    // Clean up user request counts
362
6x
    s.concurrencyMu.Lock()
363
6x
    s.userRequestCount = make(map[string]int)
364
6x
    s.concurrencyMu.Unlock()
365
6x

366
6x
    s.logger.Info(ctx, "AI Service shutdown completed")
367
6x
    return nil
368
}
369

370
// isShutdown checks if the service is shutting down
371
15x
func (s *AIService) isShutdown() bool {
372
15x
    s.shutdownMu.RLock()
373
15x
    defer s.shutdownMu.RUnlock()
374
15x
    select {
375
3x
    case <-s.shutdownCtx.Done():
376
3x
        return true
377
9x
    default:
378
9x
        return false
379
    }
380
}
381

382
// OpenAIRequest represents a request to the OpenAI-compatible API
383
type OpenAIRequest struct {
384
    Model       string    `json:"model"`
385
    Messages    []Message `json:"messages"`
386
    Temperature float64   `json:"temperature"`
387
    MaxTokens   int       `json:"max_tokens"`
388
    Grammar     string    `json:"grammar,omitempty"`
389
    Stream      bool      `json:"stream,omitempty"`
390
}
391

392
// Message represents a chat message in the API request
393
type Message struct {
394
    Role    string `json:"role"`
395
    Content string `json:"content"`
396
}
397

398
// OpenAIResponse represents a response from the OpenAI-compatible API
399
type OpenAIResponse struct {
400
    Choices []Choice  `json:"choices"`
401
    Error   *APIError `json:"error,omitempty"`
402
    Usage   *Usage    `json:"usage,omitempty"`
403
}
404

405
// Choice represents a choice in the API response
406
type Choice struct {
407
    Message Message `json:"message"`
408
}
409

410
// APIError represents an error response from the API
411
type APIError struct {
412
    Message string `json:"message"`
413
    Type    string `json:"type"`
414
}
415

416
// Usage represents token usage information from OpenAI API
417
type Usage struct {
418
    PromptTokens     int `json:"prompt_tokens"`
419
    CompletionTokens int `json:"completion_tokens"`
420
    TotalTokens      int `json:"total_tokens"`
421
}
422

423
// OpenAIStreamResponse represents a streaming response chunk from the OpenAI-compatible API
424
type OpenAIStreamResponse struct {
425
    Choices []StreamChoice `json:"choices"`
426
    Error   *APIError      `json:"error,omitempty"`
427
    Usage   *Usage         `json:"usage,omitempty"`
428
}
429

430
// StreamChoice represents a choice in the streaming API response
431
type StreamChoice struct {
432
    Delta        StreamDelta `json:"delta"`
433
    FinishReason *string     `json:"finish_reason"`
434
}
435

436
// StreamDelta represents the delta content in a streaming response
437
type StreamDelta struct {
438
    Content string `json:"content"`
439
}
440

441
// getGrammarSchema returns the appropriate JSON schema for the given question type
442
30x
func getGrammarSchema(questionType models.QuestionType) string {
443
30x
    // Always return the batch schema for each type
444
30x
    switch questionType {
445
3x
    case models.ReadingComprehension:
446
3x
        return BatchReadingComprehensionSchema
447
21x
    case models.Vocabulary:
448
21x
        return BatchVocabularyQuestionSchema
449
3x
    case models.FillInBlank:
450
3x
        return BatchQuestionsSchema
451
3x
    case models.QuestionAnswer:
452
3x
        return BatchQuestionsSchema
453
    }
454
    // Fallback for unknown types
455
    return BatchQuestionsSchema
456
}
457

458
// GetFixSchema returns the single-item JSON schema for ai-fix or an error if unsupported.
459
func GetFixSchema(questionType models.QuestionType) (string, error) {
460
    switch questionType {
461
    case models.ReadingComprehension:
462
        return SingleReadingComprehensionSchema, nil
463
    case models.Vocabulary:
464
        return SingleVocabularyQuestionSchema, nil
465
    case models.FillInBlank, models.QuestionAnswer:
466
        return SingleQuestionSchema, nil
467
    default:
468
        return "", contextutils.WrapErrorf(contextutils.ErrAIConfigInvalid, "no schema for question type: %v", questionType)
469
    }
470
}
471

472
// addJSONStructureGuidance appends JSON structure requirements to prompts for providers that don't support grammar
473
7x
func (s *AIService) addJSONStructureGuidance(prompt string, questionType models.QuestionType) string {
474
7x
    // Get the schema for this question type
475
7x
    schema := getGrammarSchema(questionType)
476
7x

477
7x
    data := AITemplateData{
478
7x
        SchemaForPrompt: schema,
479
7x
    }
480
7x

481
7x
    guidance, err := s.templateManager.RenderTemplate(JSONStructureGuidanceTemplate, data)
482
7x
    if err != nil {
483
        s.logger.Error(context.Background(), "Failed to render JSON structure guidance template", err, map[string]interface{}{})
484
        panic(err)
485
    }
486

487
7x
    return prompt + guidance
488
}
489

490
// GenerateQuestion generates a single question using AI
491
3x
func (s *AIService) GenerateQuestion(ctx context.Context, userConfig *models.UserAIConfig, req *models.AIQuestionGenRequest) (result0 *models.Question, err error) {
492
3x
    ctx, span := observability.TraceAIFunction(ctx, "generate_question",
493
3x
        attribute.String("user.username", userConfig.Username),
494
3x
        attribute.String("ai.provider", userConfig.Provider),
495
3x
        attribute.String("ai.model", userConfig.Model),
496
3x
        observability.AttributeQuestionType(string(req.QuestionType)),
497
3x
    )
498
3x
    defer observability.FinishSpan(span, &err)
499
3x
    // Check if the provider supports grammar field
500
3x
    supportsGrammar := s.supportsGrammarField(userConfig.Provider)
501
3x

502
3x
    var prompt string
503
3x
    var grammar string
504
3x

505
3x
    if supportsGrammar {
506
3x
        // Use batch prompt with count=1 for single question
507
3x
        prompt = s.buildBatchQuestionPrompt(ctx, req, nil)
508
3x
        grammar = getGrammarSchema(req.QuestionType)
509
3x
    } else {
510
        // Use batch prompt with JSON structure guidance embedded
511
        prompt = s.buildBatchQuestionPromptWithJSONStructure(ctx, req, nil)
512
        grammar = "" // No grammar field for providers that don't support it
513
    }
514

515
3x
    response, err := s.callOpenAI(ctx, userConfig, prompt, grammar)
516
3x
    if err != nil {
517
3x
        return nil, err
518
3x
    }
519

520
    question, err := s.parseQuestionResponse(ctx, response, req.Language, req.Level, req.QuestionType, userConfig.Provider)
521
    if err != nil {
522
        return nil, err
523
    }
524

525
    return question, nil
526
}
527

528
// GenerateQuestions generates multiple questions in a single batch request
529
1x
func (s *AIService) GenerateQuestions(ctx context.Context, userConfig *models.UserAIConfig, req *models.AIQuestionGenRequest) (result0 []*models.Question, err error) {
530
1x
    ctx, span := observability.TraceAIFunction(ctx, "generate_questions",
531
1x
        attribute.String("user.username", userConfig.Username),
532
1x
        attribute.String("ai.provider", userConfig.Provider),
533
1x
        attribute.String("ai.model", userConfig.Model),
534
1x
        observability.AttributeQuestionType(string(req.QuestionType)),
535
1x
        observability.AttributeLimit(req.Count),
536
1x
    )
537
1x
    defer observability.FinishSpan(span, &err)
538
1x
    // Check if the provider supports grammar field
539
1x
    supportsGrammar := s.supportsGrammarField(userConfig.Provider)
540
1x

541
1x
    var prompt string
542
1x
    var grammar string
543
1x

544
1x
    if supportsGrammar {
545
1x
        // Use regular prompt with grammar field
546
1x
        prompt = s.buildBatchQuestionPrompt(ctx, req, nil)
547
1x
        grammar = getGrammarSchema(req.QuestionType)
548
1x
    } else {
549
        // Use prompt with JSON structure guidance embedded
550
        prompt = s.buildBatchQuestionPromptWithJSONStructure(ctx, req, nil)
551
        grammar = "" // No grammar field for providers that don't support it
552
    }
553

554
1x
    response, err := s.callOpenAI(ctx, userConfig, prompt, grammar)
555
1x
    if err != nil {
556
1x
        return nil, err
557
1x
    }
558

559
    questions, err := s.parseQuestionsResponse(ctx, response, req.Language, req.Level, req.QuestionType, userConfig.Provider)
560
    if err != nil {
561
        return nil, err
562
    }
563

564
    return questions, nil
565
}
566

567
// GenerateQuestionsStream generates questions and streams them via a channel, using the provided variety elements
568
2x
func (s *AIService) GenerateQuestionsStream(ctx context.Context, userConfig *models.UserAIConfig, req *models.AIQuestionGenRequest, progress chan<- *models.Question, variety *VarietyElements) (err error) {
569
2x
    ctx, span := observability.TraceAIFunction(ctx, "generate_questions_stream",
570
2x
        attribute.String("user.username", userConfig.Username),
571
2x
        attribute.String("ai.provider", userConfig.Provider),
572
2x
        attribute.String("ai.model", userConfig.Model),
573
2x
        observability.AttributeQuestionType(string(req.QuestionType)),
574
2x
        observability.AttributeLimit(req.Count),
575
2x
    )
576
2x
    defer observability.FinishSpan(span, &err)
577
2x
    defer close(progress)
578
2x

579
2x
    return s.withConcurrencyControl(ctx, userConfig.Username, func() error {
580
2x
        // Get the batch size for this provider
581
2x
        batchSize := s.getQuestionBatchSize(userConfig.Provider)
582
2x
        // Use batch generation for multiple questions
583
2x
        return s.generateQuestionsInBatchesWithVariety(ctx, userConfig, req, progress, batchSize, variety)
584
2x
    })
585
}
586

587
// generateQuestionsInBatchesWithVariety generates questions in batches for efficiency, using the provided variety elements
588
2x
func (s *AIService) generateQuestionsInBatchesWithVariety(ctx context.Context, userConfig *models.UserAIConfig, req *models.AIQuestionGenRequest, progress chan<- *models.Question, batchSize int, variety *VarietyElements) (err error) {
589
2x
    ctx, span := observability.TraceAIFunction(ctx, "generate_questions_in_batches_with_variety",
590
2x
        attribute.String("ai.provider", userConfig.Provider),
591
2x
        attribute.String("ai.model", userConfig.Model),
592
2x
        observability.AttributeQuestionType(req.QuestionType),
593
2x
        observability.AttributeLanguage(req.Language),
594
2x
        observability.AttributeLevel(req.Level),
595
2x
        attribute.Int("batch_size", batchSize),
596
2x
        attribute.Int("total_count", req.Count),
597
2x
        attribute.Bool("variety.enabled", variety != nil),
598
2x
    )
599
2x
    defer observability.FinishSpan(span, &err)
600
2x
    // Local copy of history to be updated as we generate questions
601
2x
    localHistory := make([]string, len(req.RecentQuestionHistory))
602
2x
    copy(localHistory, req.RecentQuestionHistory)
603
2x

604
2x
    remaining := req.Count
605
2x
    generated := 0
606
2x

607
2x
    for remaining > 0 {
608
2x
        // Check for context cancellation
609
2x
        select {
610
1x
        case <-ctx.Done():
611
1x
            return ctx.Err()
612
1x
        default:
613
        }
614

615
        // Calculate how many questions to generate in this batch
616
1x
        currentBatchSize := min(remaining, batchSize)
617
1x

618
1x
        // Create a batch request
619
1x
        batchReq := &models.AIQuestionGenRequest{
620
1x
            Language:              req.Language,
621
1x
            Level:                 req.Level,
622
1x
            QuestionType:          req.QuestionType,
623
1x
            Count:                 currentBatchSize,
624
1x
            RecentQuestionHistory: localHistory,
625
1x
        }
626
1x

627
1x
        // Generate questions in batch using the provided variety elements
628
1x
        questions, err := s.generateQuestionsWithVariety(ctx, userConfig, batchReq, variety)
629
1x
        if err != nil {
630
1x
            return contextutils.WrapErrorf(err, "failed to generate batch of %d questions for user %s", currentBatchSize, userConfig.Username)
631
1x
        }
632

633
        // Stream the generated questions
634
        for _, question := range questions {
635
            // Add generated question content to history for next iterations
636
            if qContent, ok := question.Content["question"]; ok {
637
                if qStr, ok := qContent.(string); ok {
638
                    localHistory = append(localHistory, qStr)
639
                }
640
            }
641

642
            progress <- question
643
            generated++
644
        }
645

646
        remaining -= len(questions)
647
    }
648

649
    return nil
650
}
651

652
// generateQuestionsWithVariety generates a batch of questions using the provided variety elements
653
1x
func (s *AIService) generateQuestionsWithVariety(ctx context.Context, userConfig *models.UserAIConfig, req *models.AIQuestionGenRequest, variety *VarietyElements) (result0 []*models.Question, err error) {
654
1x
    ctx, span := observability.TraceAIFunction(ctx, "generate_questions_with_variety",
655
1x
        attribute.String("ai.provider", userConfig.Provider),
656
1x
        attribute.String("ai.model", userConfig.Model),
657
1x
        observability.AttributeQuestionType(req.QuestionType),
658
1x
        observability.AttributeLanguage(req.Language),
659
1x
        observability.AttributeLevel(req.Level),
660
1x
        attribute.Int("count", req.Count),
661
1x
        attribute.Bool("variety.enabled", variety != nil),
662
1x
    )
663
1x
    defer func() {
664
1x
        if err != nil {
665
1x
            span.RecordError(err, trace.WithStackTrace(true))
666
1x
            span.SetStatus(codes.Error, err.Error())
667
1x
        }
668
1x
        span.End()
669
    }()
670
    // Check if the provider supports grammar field
671
1x
    supportsGrammar := s.supportsGrammarField(userConfig.Provider)
672
1x

673
1x
    var prompt string
674
1x
    var grammar string
675
1x

676
1x
    if supportsGrammar {
677
        prompt = s.buildBatchQuestionPrompt(ctx, req, variety)
678
        grammar = getGrammarSchema(req.QuestionType)
679
    } else {
680
1x
        prompt = s.buildBatchQuestionPromptWithJSONStructure(ctx, req, variety)
681
1x
        grammar = ""
682
1x
    }
683

684
1x
    response, err := s.callOpenAI(ctx, userConfig, prompt, grammar)
685
1x
    if err != nil {
686
1x
        return nil, err
687
1x
    }
688

689
    questions, err := s.parseQuestionsResponse(ctx, response, req.Language, req.Level, req.QuestionType, userConfig.Provider)
690
    if err != nil {
691
        return nil, err
692
    }
693

694
    return questions, nil
695
}
696

697
// GenerateChatResponse generates a chat response using AI
698
1x
func (s *AIService) GenerateChatResponse(ctx context.Context, userConfig *models.UserAIConfig, req *models.AIChatRequest) (result0 string, err error) {
699
1x
    ctx, span := observability.TraceAIFunction(ctx, "generate_chat_response",
700
1x
        attribute.String("user.username", userConfig.Username),
701
1x
        attribute.String("ai.provider", userConfig.Provider),
702
1x
        attribute.String("ai.model", userConfig.Model),
703
1x
    )
704
1x
    defer observability.FinishSpan(span, &err)
705
1x
    var result string
706
1x
    var resultErr error
707
1x

708
1x
    err = s.withConcurrencyControl(ctx, userConfig.Username, func() error {
709
1x
        prompt := s.buildChatPrompt(req)
710
1x
        // No grammar constraint for open-ended chat
711
1x
        result, resultErr = s.callOpenAI(ctx, userConfig, prompt, "")
712
1x
        return resultErr
713
1x
    })
714
1x
    if err != nil {
715
1x
        return "", err
716
1x
    }
717
    return result, resultErr
718
}
719

720
// GenerateChatResponseStream generates a streaming chat response using AI
721
2x
func (s *AIService) GenerateChatResponseStream(ctx context.Context, userConfig *models.UserAIConfig, req *models.AIChatRequest, chunks chan<- string) (err error) {
722
2x
    ctx, span := observability.TraceAIFunction(ctx, "generate_chat_response_stream",
723
2x
        attribute.String("user.username", userConfig.Username),
724
2x
        attribute.String("ai.provider", userConfig.Provider),
725
2x
        attribute.String("ai.model", userConfig.Model),
726
2x
    )
727
2x
    defer observability.FinishSpan(span, &err)
728
2x
    // Don't close the channel here - let the caller handle it to avoid race conditions
729
2x

730
2x
    return s.withConcurrencyControl(ctx, userConfig.Username, func() error {
731
2x
        prompt := s.buildChatPrompt(req)
732
2x
        // No grammar constraint for open-ended chat
733
2x
        return s.callOpenAIStream(ctx, userConfig, prompt, "", chunks)
734
2x
    })
735
}
736

737
// GenerateStorySection generates a story section using AI
738
func (s *AIService) GenerateStorySection(ctx context.Context, userConfig *models.UserAIConfig, req *models.StoryGenerationRequest) (result string, err error) {
739
    ctx, span := observability.TraceAIFunction(ctx, "generate_story_section",
740
        attribute.String("user.username", userConfig.Username),
741
        attribute.String("ai.provider", userConfig.Provider),
742
        attribute.String("ai.model", userConfig.Model),
743
        attribute.String("story.title", req.Title),
744
        attribute.String("story.language", req.Language),
745
        attribute.String("story.level", req.Level),
746
        attribute.Bool("story.is_first_section", req.IsFirstSection),
747
    )
748
    defer observability.FinishSpan(span, &err)
749

750
    var storyResult string
751
    var storyErr error
752

753
    err = s.withConcurrencyControl(ctx, userConfig.Username, func() error {
754
        prompt := s.buildStorySectionPrompt(req)
755
        storyResult, storyErr = s.callOpenAIWithRetry(ctx, userConfig, prompt, "")
756
        return storyErr
757
    })
758
    if err != nil {
759
        return "", err
760
    }
761
    return storyResult, storyErr
762
}
763

764
// GenerateStoryQuestions generates comprehension questions for a story section
765
func (s *AIService) GenerateStoryQuestions(ctx context.Context, userConfig *models.UserAIConfig, req *models.StoryQuestionsRequest) (result []*models.StorySectionQuestionData, err error) {
766
    ctx, span := observability.TraceAIFunction(ctx, "generate_story_questions",
767
        attribute.String("user.username", userConfig.Username),
768
        attribute.String("ai.provider", userConfig.Provider),
769
        attribute.String("ai.model", userConfig.Model),
770
        attribute.String("story.language", req.Language),
771
        attribute.String("story.level", req.Level),
772
        attribute.Int("questions.count", req.QuestionCount),
773
    )
774
    defer observability.FinishSpan(span, &err)
775

776
    var questionsResult []*models.StorySectionQuestionData
777
    var questionsErr error
778

779
    err = s.withConcurrencyControl(ctx, userConfig.Username, func() error {
780
        prompt := s.buildStoryQuestionsPrompt(req)
781
        response, responseErr := s.callOpenAI(ctx, userConfig, prompt, "")
782
        if responseErr != nil {
783
            return responseErr
784
        }
785

786
        // Parse the JSON response into question data
787
        questionsResult, questionsErr = s.parseStoryQuestionsResponse(response)
788
        if questionsErr != nil {
789
            return contextutils.WrapErrorf(questionsErr, "failed to parse story questions response")
790
        }
791

792
        return nil
793
    })
794
    if err != nil {
795
        return nil, err
796
    }
797
    return questionsResult, questionsErr
798
}
799

800
// stringPtrToString converts a *string to string, returning empty string if nil
801
14x
func stringPtrToString(ptr *string) string {
802
14x
    if ptr == nil {
803
7x
        return ""
804
7x
    }
805
7x
    return *ptr
806
}
807

808
// buildStorySectionPrompt builds the prompt for story section generation
809
2x
func (s *AIService) buildStorySectionPrompt(req *models.StoryGenerationRequest) string {
810
2x
    // Create template data from the request
811
2x
    templateData := AITemplateData{
812
2x
        Language:           req.Language,
813
2x
        Level:              req.Level,
814
2x
        Title:              req.Title,
815
2x
        Subject:            stringPtrToString(req.Subject),
816
2x
        AuthorStyle:        stringPtrToString(req.AuthorStyle),
817
2x
        TimePeriod:         stringPtrToString(req.TimePeriod),
818
2x
        Genre:              stringPtrToString(req.Genre),
819
2x
        Tone:               stringPtrToString(req.Tone),
820
2x
        CharacterNames:     stringPtrToString(req.CharacterNames),
821
2x
        CustomInstructions: stringPtrToString(req.CustomInstructions),
822
2x
        TargetWords:        req.TargetWords,
823
2x
        TargetSentences:    req.TargetSentences,
824
2x
        IsFirstSection:     req.IsFirstSection,
825
2x
        PreviousSections:   req.PreviousSections,
826
2x
    }
827
2x

828
2x
    template, err := s.templateManager.RenderTemplate("story_section_prompt.tmpl", templateData)
829
2x
    if err != nil {
830
        // No fallback - error out if template not found
831
        panic(contextutils.WrapErrorf(err, "failed to render story section template"))
832
    }
833

834
2x
    return template
835
}
836

837
// buildStoryQuestionsPrompt builds the prompt for story questions generation
838
1x
func (s *AIService) buildStoryQuestionsPrompt(req *models.StoryQuestionsRequest) string {
839
1x
    // Create template data from the request
840
1x
    templateData := AITemplateData{
841
1x
        Language:    req.Language,
842
1x
        Level:       req.Level,
843
1x
        Count:       req.QuestionCount,
844
1x
        SectionText: req.SectionText,
845
1x
    }
846
1x

847
1x
    template, err := s.templateManager.RenderTemplate("story_questions_prompt.tmpl", templateData)
848
1x
    if err != nil {
849
        // No fallback - error out if template not found
850
        panic(contextutils.WrapErrorf(err, "failed to render story questions template"))
851
    }
852

853
1x
    return template
854
}
855

856
// parseStoryQuestionsResponse parses the AI response into question data
857
func (s *AIService) parseStoryQuestionsResponse(response string) ([]*models.StorySectionQuestionData, error) {
858
    // Clean the response (remove markdown code blocks if present)
859
    response = strings.TrimSpace(response)
860
    if strings.HasPrefix(response, "```json") {
861
        response = strings.TrimPrefix(response, "```json")
862
        response = strings.TrimSuffix(response, "```")
863
        response = strings.TrimSpace(response)
864
    }
865

866
    var questions []*models.StorySectionQuestionData
867
    if err := json.Unmarshal([]byte(response), &questions); err != nil {
868
        return nil, contextutils.WrapErrorf(err, "failed to unmarshal questions JSON")
869
    }
870

871
    // Validate the questions
872
    for i, q := range questions {
873
        if q.QuestionText == "" {
874
            return nil, contextutils.ErrorWithContextf("question %d: missing question text", i)
875
        }
876
        if len(q.Options) != 4 {
877
            return nil, contextutils.ErrorWithContextf("question %d: must have exactly 4 options, got %d", i, len(q.Options))
878
        }
879
        if q.CorrectAnswerIndex < 0 || q.CorrectAnswerIndex >= 4 {
880
            return nil, contextutils.ErrorWithContextf("question %d: correct_answer_index must be 0-3, got %d", i, q.CorrectAnswerIndex)
881
        }
882
    }
883

884
    return questions, nil
885
}
886

887
// TestConnection tests the connection to the AI service
888
3x
func (s *AIService) TestConnection(ctx context.Context, provider, model, apiKey string) (err error) {
889
3x
    _, span := observability.TraceAIFunction(ctx, "test_connection",
890
3x
        attribute.String("ai.provider", provider),
891
3x
        attribute.String("ai.model", model),
892
3x
    )
893
3x
    defer observability.FinishSpan(span, &err)
894
3x

895
3x
    // Validate input parameters
896
3x
    if provider == "" {
897
        span.SetAttributes(attribute.String("test.result", "empty_provider"))
898
        return contextutils.WrapError(contextutils.ErrAIConfigInvalid, "provider is required for testing connection")
899
    }
900

901
3x
    if model == "" {
902
        span.SetAttributes(attribute.String("test.result", "empty_model"))
903
        return contextutils.WrapError(contextutils.ErrAIConfigInvalid, "model is required for testing connection")
904
    }
905

906
3x
    s.logger.Debug(ctx, "TestConnection called", map[string]interface{}{
907
3x
        "provider": provider,
908
3x
        "model":    model,
909
3x
        "apiKey":   contextutils.MaskAPIKey(apiKey),
910
3x
    })
911
3x

912
3x
    // Require API key for all providers that are not Ollama
913
3x
    if provider != "ollama" && apiKey == "" {
914
1x
        span.SetAttributes(attribute.String("test.result", "missing_api_key"), attribute.String("provider", provider))
915
1x
        return contextutils.WrapErrorf(contextutils.ErrAIConfigInvalid, "API key is required for testing connection with provider '%s'", provider)
916
1x
    }
917

918
    // Create a simple test configuration
919
2x
    userConfig := &models.UserAIConfig{
920
2x
        Provider: provider,
921
2x
        Model:    model,
922
2x
        APIKey:   apiKey,
923
2x
        Username: "test-user",
924
2x
    }
925
2x

926
2x
    s.logger.Debug(ctx, "Created userConfig", map[string]interface{}{
927
2x
        "provider": userConfig.Provider,
928
2x
        "model":    userConfig.Model,
929
2x
    })
930
2x

931
2x
    // Create a simple test request
932
2x
    testPrompt := "Respond with exactly the word 'SUCCESS' and nothing else."
933
2x

934
2x
    // Create a timeout context for the test
935
2x
    testCtx, cancel := context.WithTimeout(ctx, config.AIRequestTimeout)
936
2x
    defer cancel()
937
2x

938
2x
    // Test the actual AI service call
939
2x
    response, err := s.callOpenAI(testCtx, userConfig, testPrompt, "")
940
2x
    if err != nil {
941
2x
        span.SetAttributes(attribute.String("test.result", "call_failed"), attribute.String("error", err.Error()))
942
2x
        return contextutils.WrapErrorf(err, "connection test failed for provider '%s' with model '%s'", provider, model)
943
2x
    }
944

945
    // Check if we got a reasonable response
946
    if response == "" {
947
        span.SetAttributes(attribute.String("test.result", "empty_response"))
948
        return contextutils.WrapError(contextutils.ErrAIResponseInvalid, "connection test failed: received empty response from AI service")
949
    }
950

951
    // Validate that the response contains something meaningful
952
    if len(response) < 3 {
953
        span.SetAttributes(attribute.String("test.result", "response_too_short"), attribute.Int("response_length", len(response)))
954
        return contextutils.WrapErrorf(contextutils.ErrAIResponseInvalid, "connection test failed: response too short (%d characters)", len(response))
955
    }
956

957
    // The response should contain something meaningful
958
    s.logger.Info(ctx, "TestConnection successful", map[string]interface{}{
959
        "provider":        provider,
960
        "response_length": len(response),
961
    })
962
    span.SetAttributes(attribute.String("test.result", "success"), attribute.Int("response_length", len(response)))
963
    return nil
964
}
965

966
// buildBatchQuestionPromptWithJSONStructure now takes variety elements
967
3x
func (s *AIService) buildBatchQuestionPromptWithJSONStructure(ctx context.Context, req *models.AIQuestionGenRequest, variety *VarietyElements) string {
968
3x
    prompt := s.buildBatchQuestionPrompt(ctx, req, variety)
969
3x
    return s.addJSONStructureGuidance(prompt, req.QuestionType)
970
3x
}
971

972
// buildBatchQuestionPrompt now takes variety elements
973
11x
func (s *AIService) buildBatchQuestionPrompt(ctx context.Context, req *models.AIQuestionGenRequest, variety *VarietyElements) string {
974
11x
    _, span := observability.TraceAIFunction(ctx, "build_batch_question_prompt",
975
11x
        observability.AttributeQuestionType(req.QuestionType),
976
11x
        observability.AttributeLanguage(req.Language),
977
11x
        observability.AttributeLevel(req.Level),
978
11x
        attribute.Int("count", req.Count),
979
11x
        attribute.Bool("variety.enabled", variety != nil),
980
11x
    )
981
11x
    defer span.End()
982
11x
    tmplData := AITemplateData{
983
11x
        SchemaForPrompt:       getGrammarSchema(req.QuestionType),
984
11x
        Language:              req.Language,
985
11x
        Level:                 req.Level,
986
11x
        QuestionType:          string(req.QuestionType),
987
11x
        Count:                 req.Count,
988
11x
        RecentQuestionHistory: req.RecentQuestionHistory,
989
11x
    }
990
11x
    if variety != nil {
991
4x
        tmplData.TopicCategory = variety.TopicCategory
992
4x
        tmplData.GrammarFocus = variety.GrammarFocus
993
4x
        tmplData.VocabularyDomain = variety.VocabularyDomain
994
4x
        tmplData.Scenario = variety.Scenario
995
4x
        tmplData.StyleModifier = variety.StyleModifier
996
4x
        tmplData.DifficultyModifier = variety.DifficultyModifier
997
4x
        tmplData.TimeContext = variety.TimeContext
998
4x
    }
999

1000
    // Priority data is handled by the worker, not passed to AI service
1001

1002
    // Load example for this question type
1003
11x
    if exampleContent, err := s.templateManager.LoadExample(string(req.QuestionType)); err == nil {
1004
11x
        tmplData.ExampleContent = exampleContent
1005
11x
    }
1006

1007
11x
    prompt, err := s.templateManager.RenderTemplate(BatchQuestionPromptTemplate, tmplData)
1008
11x
    if err != nil {
1009
        s.logger.Error(ctx, "Failed to render batch question prompt template", err, map[string]interface{}{})
1010
        panic(err) // Use panic for fatal errors in template rendering
1011
    }
1012

1013
11x
    return prompt
1014
}
1015

1016
14x
func (s *AIService) buildChatPrompt(req *models.AIChatRequest) string {
1017
14x
    // Convert conversation history to template format
1018
14x
    var conversationHistory []ChatMessage
1019
14x
    for _, msg := range req.ConversationHistory {
1020
        conversationHistory = append(conversationHistory, ChatMessage{
1021
            Role:    string(msg.Role),
1022
            Content: msg.Content,
1023
        })
1024
    }
1025

1026
14x
    data := AITemplateData{
1027
14x
        Language:            req.Language,
1028
14x
        Level:               req.Level,
1029
14x
        QuestionType:        string(req.QuestionType),
1030
14x
        Passage:             req.Passage,
1031
14x
        Question:            req.Question,
1032
14x
        Options:             req.Options,
1033
14x
        IsCorrect:           req.IsCorrect,
1034
14x
        ConversationHistory: conversationHistory,
1035
14x
        UserMessage:         req.UserMessage,
1036
14x
    }
1037
14x

1038
14x
    prompt, err := s.templateManager.RenderTemplate(ChatPromptTemplate, data)
1039
14x
    if err != nil {
1040
        s.logger.Error(context.Background(), "Failed to render chat prompt template", err, map[string]interface{}{})
1041
        panic(err) // Use panic for fatal errors in template rendering
1042
    }
1043

1044
14x
    return prompt
1045
}
1046

1047
// getMaxTokensForModel looks up the max_tokens for a specific provider and model
1048
10x
func (s *AIService) getMaxTokensForModel(provider, model string) int {
1049
10x
    // Look up the model in the provider configuration
1050
10x
    if s.cfg.Providers != nil {
1051
10x
        for _, providerConfig := range s.cfg.Providers {
1052
12x
            if providerConfig.Code == provider {
1053
6x
                for _, modelConfig := range providerConfig.Models {
1054
                    if modelConfig.Code == model {
1055
                        if modelConfig.MaxTokens > 0 {
1056
                            return modelConfig.MaxTokens
1057
                        }
1058
                        break
1059
                    }
1060
                }
1061
6x
                break
1062
            }
1063
        }
1064
    }
1065

1066
    // Default fallback
1067
10x
    return 4000
1068
}
1069

1070
// callOpenAI makes a request to the OpenAI-compatible API
1071
8x
func (s *AIService) callOpenAI(ctx context.Context, userConfig *models.UserAIConfig, prompt, grammar string) (result0 string, err error) {
1072
8x
    if userConfig == nil {
1073
        return "", contextutils.WrapError(contextutils.ErrAIConfigInvalid, "userConfig is required")
1074
    }
1075
8x
    ctx, span := observability.TraceAIFunction(ctx, "call_openai",
1076
8x
        attribute.String("ai.provider", userConfig.Provider),
1077
8x
        attribute.String("ai.model", userConfig.Model),
1078
8x
        attribute.String("ai.username", userConfig.Username),
1079
8x
        attribute.Int("prompt.length", len(prompt)),
1080
8x
        attribute.Bool("grammar.enabled", grammar != ""),
1081
8x
    )
1082
8x
    defer func() {
1083
8x
        if err != nil {
1084
8x
            span.RecordError(err, trace.WithStackTrace(true))
1085
8x
            span.SetStatus(codes.Error, err.Error())
1086
8x
        }
1087
8x
        span.End()
1088
    }()
1089

1090
    // Validate input parameters
1091
8x
    if userConfig.Provider == "" {
1092
        span.SetAttributes(attribute.String("call.result", "empty_provider"))
1093
        return "", contextutils.WrapError(contextutils.ErrAIConfigInvalid, "provider is required")
1094
    }
1095

1096
8x
    if userConfig.Model == "" {
1097
        span.SetAttributes(attribute.String("call.result", "empty_model"))
1098
        return "", contextutils.WrapError(contextutils.ErrAIConfigInvalid, "model is required")
1099
    }
1100

1101
8x
    if prompt == "" {
1102
        span.SetAttributes(attribute.String("call.result", "empty_prompt"))
1103
        return "", contextutils.WrapError(contextutils.ErrAIConfigInvalid, "prompt cannot be empty")
1104
    }
1105

1106
8x
    apiURL := ""
1107
8x
    model := userConfig.Model
1108
8x
    apiKey := userConfig.APIKey
1109
8x

1110
8x
    // Look up the default URL from provider config
1111
8x
    if s.cfg.Providers != nil {
1112
8x
        for _, providerConfig := range s.cfg.Providers {
1113
8x
            if providerConfig.Code == userConfig.Provider && providerConfig.URL != "" {
1114
4x
                apiURL = providerConfig.URL
1115
4x
                break
1116
            }
1117
        }
1118
    }
1119

1120
8x
    if apiURL == "" {
1121
4x
        span.SetAttributes(attribute.String("call.result", "no_url_configured"), attribute.String("provider", userConfig.Provider))
1122
4x
        return "", contextutils.WrapErrorf(contextutils.ErrAIConfigInvalid, "no base URL configured for provider '%s'", userConfig.Provider)
1123
4x
    }
1124

1125
4x
    userPrefix := ""
1126
4x
    if userConfig.Username != "" {
1127
        userPrefix = fmt.Sprintf("[user=%s] ", userConfig.Username)
1128
    }
1129

1130
4x
    s.logger.Debug(ctx, "Starting AI request", map[string]interface{}{
1131
4x
        "user_prefix": userPrefix,
1132
4x
        "url":         apiURL + "/chat/completions",
1133
4x
        "model":       model,
1134
4x
        "provider":    userConfig.Provider,
1135
4x
    })
1136
4x

1137
4x
    // Create messages with just the user prompt - grammar field will enforce JSON structure
1138
4x
    messages := []Message{{Role: "user", Content: prompt}}
1139
4x

1140
4x
    // Check if the provider supports grammar field
1141
4x
    supportsGrammar := s.supportsGrammarField(userConfig.Provider)
1142
4x

1143
4x
    reqBody := OpenAIRequest{
1144
4x
        Model:       model,
1145
4x
        Messages:    messages,
1146
4x
        Temperature: 0.7,
1147
4x
        MaxTokens:   s.getMaxTokensForModel(userConfig.Provider, userConfig.Model),
1148
4x
    }
1149
4x

1150
4x
    // Only include grammar field if the provider supports it
1151
4x
    if supportsGrammar && grammar != "" {
1152
4x
        reqBody.Grammar = grammar
1153
4x
    }
1154

1155
4x
    jsonData, err := json.Marshal(reqBody)
1156
4x
    if err != nil {
1157
        s.logger.Error(ctx, "Failed to marshal AI request", err, map[string]interface{}{
1158
            "user_prefix": userPrefix,
1159
        })
1160
        span.SetAttributes(attribute.String("call.result", "marshal_failed"), attribute.String("error", err.Error()))
1161
        return "", contextutils.WrapErrorf(err, "failed to marshal request body")
1162
    }
1163

1164
4x
    s.logger.Debug(ctx, "Making AI HTTP request", map[string]interface{}{
1165
4x
        "user_prefix": userPrefix,
1166
4x
        "url":         apiURL + "/chat/completions",
1167
4x
    })
1168
4x
    req, err := http.NewRequestWithContext(ctx, "POST", apiURL+"/chat/completions", bytes.NewBuffer(jsonData))
1169
4x
    if err != nil {
1170
        s.logger.Error(ctx, "Failed to create AI HTTP request", err, map[string]interface{}{
1171
            "user_prefix": userPrefix,
1172
        })
1173
        span.SetAttributes(attribute.String("call.result", "request_creation_failed"), attribute.String("error", err.Error()))
1174
        return "", contextutils.WrapErrorf(err, "failed to create HTTP request")
1175
    }
1176

1177
4x
    req.Header.Set("Content-Type", "application/json")
1178
4x
    req.Header.Set("User-Agent", "quizapp/1.0")
1179
4x
    if apiKey != "" {
1180
        req.Header.Set("Authorization", "Bearer "+apiKey)
1181
        s.logger.Debug(ctx, "Using API key authentication", map[string]interface{}{
1182
            "user_prefix": userPrefix,
1183
        })
1184
    } else {
1185
4x
        s.logger.Debug(ctx, "No API key provided, using anonymous access", map[string]interface{}{
1186
4x
            "user_prefix": userPrefix,
1187
4x
        })
1188
4x
    }
1189

1190
4x
    startTime := time.Now()
1191
4x
    resp, err := s.httpClient.Do(req.WithContext(ctx))
1192
4x
    duration := time.Since(startTime)
1193
4x

1194
4x
    if err != nil {
1195
1x
        s.logger.Error(ctx, "AI HTTP request failed", err, map[string]interface{}{
1196
1x
            "user_prefix": userPrefix,
1197
1x
            "duration":    duration.String(),
1198
1x
        })
1199
1x
        span.SetAttributes(attribute.String("call.result", "http_request_failed"), attribute.String("error", err.Error()), attribute.String("duration", duration.String()))
1200
1x
        return "", contextutils.WrapErrorf(err, "HTTP request failed after %v", duration)
1201
1x
    }
1202
3x
    defer func() {
1203
3x
        if err := resp.Body.Close(); err != nil {
1204
            s.logger.Warn(ctx, "Failed to close response body", map[string]interface{}{
1205
                "error": err.Error(),
1206
            })
1207
        }
1208
    }()
1209

1210
3x
    s.logger.Info(ctx, "AI Service HTTP request completed", map[string]interface{}{
1211
3x
        "user_prefix": userPrefix,
1212
3x
        "duration":    duration.String(),
1213
3x
        "status_code": resp.StatusCode,
1214
3x
    })
1215
3x

1216
3x
    body, err := io.ReadAll(resp.Body)
1217
3x
    if err != nil {
1218
        span.SetAttributes(attribute.String("call.result", "body_read_failed"), attribute.String("error", err.Error()))
1219
        return "", contextutils.WrapErrorf(err, "failed to read response body")
1220
    }
1221

1222
3x
    if resp.StatusCode != http.StatusOK {
1223
1x
        span.SetAttributes(attribute.String("call.result", "http_error"), attribute.Int("status_code", resp.StatusCode), attribute.String("body", string(body)))
1224
1x

1225
1x
        // Handle rate limit errors specifically
1226
1x
        if resp.StatusCode == http.StatusTooManyRequests {
1227
            return "", contextutils.WrapErrorf(contextutils.ErrRateLimit, "Rate limit exceeded for AI provider %s: %s", userConfig.Provider, string(body))
1228
        }
1229

1230
1x
        return "", contextutils.WrapErrorf(contextutils.ErrAIRequestFailed, "API request failed with status %d to %s: %s", resp.StatusCode, apiURL+"/chat/completions", string(body))
1231
    }
1232

1233
2x
    var openAIResp OpenAIResponse
1234
2x
    if err := json.Unmarshal(body, &openAIResp); err != nil {
1235
1x
        span.SetAttributes(attribute.String("call.result", "json_unmarshal_failed"), attribute.String("error", err.Error()), attribute.String("body", string(body)))
1236
1x
        return "", contextutils.WrapErrorf(contextutils.ErrAIResponseInvalid, "failed to parse AI response as JSON: %w. Raw Response: %s", err, string(body))
1237
1x
    }
1238

1239
1x
    if openAIResp.Error != nil {
1240
        span.SetAttributes(attribute.String("call.result", "api_error"), attribute.String("error_message", openAIResp.Error.Message), attribute.String("error_type", openAIResp.Error.Type))
1241
        return "", contextutils.WrapErrorf(contextutils.ErrAIRequestFailed, "OpenAI API error: %s", openAIResp.Error.Message)
1242
    }
1243

1244
1x
    if len(openAIResp.Choices) == 0 {
1245
1x
        span.SetAttributes(attribute.String("call.result", "no_choices"))
1246
1x
        return "", contextutils.WrapError(contextutils.ErrAIResponseInvalid, "no response from OpenAI")
1247
1x
    }
1248

1249
    content := openAIResp.Choices[0].Message.Content
1250
    if content == "" {
1251
        span.SetAttributes(attribute.String("call.result", "empty_content"))
1252
        s.logger.Warn(ctx, "OpenAI returned empty content", map[string]interface{}{
1253
            "user_prefix":   userPrefix,
1254
            "response":      string(body),
1255
            "prompt_length": len(prompt),
1256
        })
1257
        return "", contextutils.WrapError(contextutils.ErrAIResponseInvalid, "AI returned empty content")
1258
    }
1259

1260
    span.SetAttributes(attribute.String("call.result", "success"), attribute.Int("content_length", len(content)), attribute.String("duration", duration.String()))
1261

1262
    // Extract usage information if available and track it internally
1263
    if openAIResp.Usage != nil {
1264
        userID := contextutils.GetUserIDFromContext(ctx)
1265
        apiKeyID := contextutils.GetAPIKeyIDFromContext(ctx)
1266
        s.trackAIUsage(ctx, userConfig, *openAIResp.Usage, userID, apiKeyID)
1267
    } else {
1268
        s.logger.Warn(ctx, "No usage information available", map[string]any{
1269
            "user_prefix":   userPrefix,
1270
            "response":      string(body),
1271
            "prompt_length": len(prompt),
1272
        })
1273
        span.SetAttributes(attribute.String("call.result", "no_usage_information"), attribute.String("response", string(body)), attribute.String("prompt_length", strconv.Itoa(len(prompt))))
1274
    }
1275

1276
    return content, nil
1277
}
1278

1279
// callOpenAIWithRetry attempts to call OpenAI with retry logic for empty content responses
1280
func (s *AIService) callOpenAIWithRetry(ctx context.Context, userConfig *models.UserAIConfig, prompt, grammar string) (result string, err error) {
1281
    _, span := observability.TraceAIFunction(ctx, "call_openai_with_retry",
1282
        attribute.String("ai.provider", userConfig.Provider),
1283
        attribute.String("ai.model", userConfig.Model),
1284
        attribute.String("ai.username", userConfig.Username),
1285
        attribute.Int("prompt.length", len(prompt)),
1286
        attribute.Bool("grammar.enabled", grammar != ""),
1287
    )
1288
    defer observability.FinishSpan(span, &err)
1289

1290
    const maxRetries = 2
1291
    var lastErr error
1292

1293
    for attempt := 0; attempt <= maxRetries; attempt++ {
1294
        if attempt > 0 {
1295
            // Add a small delay between retries
1296
            time.Sleep(time.Duration(attempt) * time.Second)
1297
        }
1298

1299
        result, err = s.callOpenAI(ctx, userConfig, prompt, grammar)
1300
        if err != nil {
1301
            // If it's not an empty content error, don't retry
1302
            if !contextutils.IsError(err, contextutils.ErrAIResponseInvalid) {
1303
                return result, err
1304
            }
1305

1306
            lastErr = err
1307

1308
            // If this is the last attempt, return the error
1309
            if attempt == maxRetries {
1310
                break
1311
            }
1312

1313
            s.logger.Warn(ctx, "Retrying AI request due to empty content", map[string]interface{}{
1314
                "attempt":     attempt + 1,
1315
                "max_retries": maxRetries,
1316
                "error":       err.Error(),
1317
            })
1318

1319
            continue
1320
        }
1321

1322
        return result, nil
1323
    }
1324

1325
    return result, contextutils.WrapErrorf(lastErr, "AI returned empty content after %d attempts", maxRetries+1)
1326
}
1327

1328
// callOpenAIStream makes a streaming request to the OpenAI-compatible API
1329
2x
func (s *AIService) callOpenAIStream(ctx context.Context, userConfig *models.UserAIConfig, prompt, grammar string, chunks chan<- string) (err error) {
1330
2x
    if userConfig == nil {
1331
        return contextutils.WrapError(contextutils.ErrAIConfigInvalid, "userConfig is required")
1332
    }
1333
2x
    _, span := observability.TraceAIFunction(ctx, "call_openai_stream",
1334
2x
        attribute.String("ai.provider", userConfig.Provider),
1335
2x
        attribute.String("ai.model", userConfig.Model),
1336
2x
        attribute.String("ai.username", userConfig.Username),
1337
2x
        attribute.Int("prompt.length", len(prompt)),
1338
2x
        attribute.Bool("grammar.enabled", grammar != ""),
1339
2x
    )
1340
2x
    defer observability.FinishSpan(span, &err)
1341
2x

1342
2x
    // Validate input parameters
1343
2x
    if userConfig.Provider == "" {
1344
        span.SetAttributes(attribute.String("stream.result", "empty_provider"))
1345
        return contextutils.WrapError(contextutils.ErrAIConfigInvalid, "provider is required")
1346
    }
1347

1348
2x
    if userConfig.Model == "" {
1349
        span.SetAttributes(attribute.String("stream.result", "empty_model"))
1350
        return contextutils.WrapError(contextutils.ErrAIConfigInvalid, "model is required")
1351
    }
1352

1353
2x
    if prompt == "" {
1354
        span.SetAttributes(attribute.String("stream.result", "empty_prompt"))
1355
        return contextutils.WrapError(contextutils.ErrAIConfigInvalid, "prompt cannot be empty")
1356
    }
1357

1358
2x
    if chunks == nil {
1359
        span.SetAttributes(attribute.String("stream.result", "nil_chunks_channel"))
1360
        return contextutils.WrapError(contextutils.ErrAIConfigInvalid, "chunks channel is required")
1361
    }
1362

1363
2x
    apiURL := ""
1364
2x
    model := userConfig.Model
1365
2x
    apiKey := userConfig.APIKey
1366
2x

1367
2x
    // Look up the default URL from provider config
1368
2x
    if s.cfg.Providers != nil {
1369
2x
        for _, providerConfig := range s.cfg.Providers {
1370
4x
            if providerConfig.Code == userConfig.Provider && providerConfig.URL != "" {
1371
2x
                apiURL = providerConfig.URL
1372
2x
                break
1373
            }
1374
        }
1375
    }
1376

1377
2x
    if apiURL == "" {
1378
        span.SetAttributes(attribute.String("stream.result", "no_url_configured"), attribute.String("provider", userConfig.Provider))
1379
        return contextutils.WrapErrorf(contextutils.ErrAIConfigInvalid, "no base URL configured for provider '%s'", userConfig.Provider)
1380
    }
1381

1382
2x
    userPrefix := ""
1383
2x
    if userConfig.Username != "" {
1384
2x
        userPrefix = fmt.Sprintf("[user=%s] ", userConfig.Username)
1385
2x
    }
1386

1387
2x
    s.logger.Info(ctx, "AI Service Starting streaming request", map[string]interface{}{
1388
2x
        "user_prefix": userPrefix,
1389
2x
        "api_url":     apiURL + "/chat/completions",
1390
2x
        "model":       model,
1391
2x
        "provider":    userConfig.Provider,
1392
2x
    })
1393
2x

1394
2x
    // Create messages with just the user prompt - grammar field will enforce JSON structure
1395
2x
    messages := []Message{{Role: "user", Content: prompt}}
1396
2x

1397
2x
    // Check if the provider supports grammar field
1398
2x
    supportsGrammar := s.supportsGrammarField(userConfig.Provider)
1399
2x

1400
2x
    reqBody := OpenAIRequest{
1401
2x
        Model:       model,
1402
2x
        Messages:    messages,
1403
2x
        Temperature: 0.7,
1404
2x
        MaxTokens:   s.getMaxTokensForModel(userConfig.Provider, userConfig.Model),
1405
2x
        Stream:      true, // Enable streaming
1406
2x
    }
1407
2x

1408
2x
    // Only include grammar field if the provider supports it
1409
2x
    if supportsGrammar && grammar != "" {
1410
        reqBody.Grammar = grammar
1411
    }
1412

1413
2x
    jsonData, err := json.Marshal(reqBody)
1414
2x
    if err != nil {
1415
        s.logger.Error(ctx, "Failed to marshal request", err, map[string]interface{}{
1416
            "user_prefix": userPrefix,
1417
        })
1418
        span.SetAttributes(attribute.String("stream.result", "marshal_failed"), attribute.String("error", err.Error()))
1419
        return contextutils.WrapErrorf(err, "failed to marshal streaming request body")
1420
    }
1421

1422
2x
    s.logger.Info(ctx, "AI Service Making streaming HTTP request", map[string]interface{}{
1423
2x
        "user_prefix": userPrefix,
1424
2x
        "api_url":     apiURL + "/chat/completions",
1425
2x
    })
1426
2x
    req, err := http.NewRequestWithContext(ctx, "POST", apiURL+"/chat/completions", bytes.NewBuffer(jsonData))
1427
2x
    if err != nil {
1428
        s.logger.Error(ctx, "Failed to create HTTP request", err, map[string]interface{}{
1429
            "user_prefix": userPrefix,
1430
        })
1431
        span.SetAttributes(attribute.String("stream.result", "request_creation_failed"), attribute.String("error", err.Error()))
1432
        return contextutils.WrapErrorf(err, "failed to create streaming HTTP request")
1433
    }
1434

1435
2x
    req.Header.Set("Content-Type", "application/json")
1436
2x
    req.Header.Set("Accept", "text/event-stream")
1437
2x
    req.Header.Set("Cache-Control", "no-cache")
1438
2x
    req.Header.Set("User-Agent", "quizapp/1.0")
1439
2x
    if apiKey != "" {
1440
2x
        req.Header.Set("Authorization", "Bearer "+apiKey)
1441
2x
        s.logger.Info(ctx, "AI Service Using API key authentication", map[string]interface{}{
1442
2x
            "user_prefix": userPrefix,
1443
2x
        })
1444
2x
    } else {
1445
        s.logger.Info(ctx, "AI Service No API key provided, using anonymous access", map[string]interface{}{
1446
            "user_prefix": userPrefix,
1447
        })
1448
    }
1449

1450
2x
    startTime := time.Now()
1451
2x
    resp, err := s.httpClient.Do(req.WithContext(ctx))
1452
2x
    if err != nil {
1453
2x
        s.logger.Error(ctx, "HTTP request failed", err, map[string]interface{}{
1454
2x
            "user_prefix": userPrefix,
1455
2x
        })
1456
2x
        span.SetAttributes(attribute.String("stream.result", "http_request_failed"), attribute.String("error", err.Error()))
1457
2x
        return contextutils.WrapErrorf(contextutils.ErrAIRequestFailed, "http client error: %w", err)
1458
2x
    }
1459
    defer func() {
1460
        if err := resp.Body.Close(); err != nil {
1461
            s.logger.Warn(ctx, "Failed to close response body", map[string]interface{}{
1462
                "error": err.Error(),
1463
            })
1464
        }
1465
    }()
1466

1467
    if resp.StatusCode != http.StatusOK {
1468
        body, _ := io.ReadAll(resp.Body)
1469
        span.SetAttributes(attribute.String("stream.result", "http_error"), attribute.Int("status_code", resp.StatusCode), attribute.String("body", string(body)))
1470

1471
        // Handle rate limit errors specifically
1472
        if resp.StatusCode == http.StatusTooManyRequests {
1473
            return contextutils.WrapErrorf(contextutils.ErrRateLimit, "Rate limit exceeded for AI provider %s: %s", userConfig.Provider, string(body))
1474
        }
1475

1476
        return contextutils.WrapErrorf(contextutils.ErrAIRequestFailed, "API request failed with status %d to %s: %s", resp.StatusCode, apiURL+"/chat/completions", string(body))
1477
    }
1478

1479
    s.logger.Info(ctx, "AI Service Streaming response started", map[string]interface{}{
1480
        "user_prefix": userPrefix,
1481
        "duration":    time.Since(startTime).String(),
1482
    })
1483

1484
    // Read the streaming response
1485
    scanner := bufio.NewScanner(resp.Body)
1486
    var chunkCount int
1487
    var totalContentLength int
1488
    var finalUsage *Usage
1489

1490
    // Usage information may or may not be included in streaming response chunks depending on the provider.
1491
    // We'll only try to extract usage from chunks for providers that support it in streaming responses.
1492
    // For providers that don't support usage in streaming, usage data is available via response.UsageMetadata in non-streaming calls.
1493

1494
    for scanner.Scan() {
1495
        line := scanner.Text()
1496

1497
        // Skip empty lines and comments
1498
        if line == "" || strings.HasPrefix(line, ":") {
1499
            continue
1500
        }
1501

1502
        // Parse Server-Sent Events format
1503
        if strings.HasPrefix(line, "data: ") {
1504
            data := strings.TrimPrefix(line, "data: ")
1505

1506
            // Check for end of stream
1507
            if data == "[DONE]" {
1508
                break
1509
            }
1510

1511
            // Parse the JSON chunk
1512
            var streamResp OpenAIStreamResponse
1513
            if err := json.Unmarshal([]byte(data), &streamResp); err != nil {
1514
                s.logger.Warn(ctx, "AI Service WARNING: Failed to parse streaming chunk", map[string]interface{}{
1515
                    "error": err.Error(),
1516
                    "data":  data,
1517
                })
1518
                span.SetAttributes(attribute.String("stream.result", "chunk_parse_failed"), attribute.String("error", err.Error()), attribute.String("data", data))
1519
                continue
1520
            }
1521

1522
            if streamResp.Error != nil {
1523
                span.SetAttributes(attribute.String("stream.result", "api_streaming_error"), attribute.String("error_message", streamResp.Error.Message), attribute.String("error_type", streamResp.Error.Type))
1524
                return contextutils.WrapErrorf(contextutils.ErrAIRequestFailed, "OpenAI API streaming error: %s", streamResp.Error.Message)
1525
            }
1526

1527
            // Extract usage information if available (usually in the final chunk)
1528
            // Only check for usage if the provider supports it in streaming responses
1529
            if streamResp.Usage != nil && s.supportsUsageInStreaming(userConfig.Provider) {
1530
                finalUsage = streamResp.Usage
1531
            }
1532

1533
            // Extract content from the chunk
1534
            if len(streamResp.Choices) > 0 && streamResp.Choices[0].Delta.Content != "" {
1535
                content := streamResp.Choices[0].Delta.Content
1536
                totalContentLength += len(content)
1537

1538
                // Filter out thinking content for thinking models
1539
                filteredContent := s.filterThinkingContent(content, model)
1540

1541
                if filteredContent != "" {
1542
                    select {
1543
                    case chunks <- filteredContent:
1544
                        chunkCount++
1545
                    case <-ctx.Done():
1546
                        span.SetAttributes(attribute.String("stream.result", "context_cancelled"))
1547
                        return ctx.Err()
1548
                    }
1549
                }
1550
            }
1551

1552
            // Check if streaming is finished
1553
            if len(streamResp.Choices) > 0 && streamResp.Choices[0].FinishReason != nil {
1554
                break
1555
            }
1556
        }
1557
    }
1558

1559
    if err := scanner.Err(); err != nil {
1560
        span.SetAttributes(attribute.String("stream.result", "scanner_error"), attribute.String("error", err.Error()))
1561
        return contextutils.WrapErrorf(contextutils.ErrAIRequestFailed, "error reading streaming response: %w", err)
1562
    }
1563

1564
    s.logger.Info(ctx, "AI Service Streaming response completed", map[string]interface{}{
1565
        "user_prefix":          userPrefix,
1566
        "duration":             time.Since(startTime).String(),
1567
        "chunk_count":          chunkCount,
1568
        "total_content_length": totalContentLength,
1569
    })
1570

1571
    // Extract usage information if available and track it internally
1572
    if finalUsage != nil {
1573
        userID := contextutils.GetUserIDFromContext(ctx)
1574
        apiKeyID := contextutils.GetAPIKeyIDFromContext(ctx)
1575
        s.trackAIUsage(ctx, userConfig, *finalUsage, userID, apiKeyID)
1576
    } else {
1577
        // For providers that don't support usage in streaming, this is expected behavior
1578
        if !s.supportsUsageInStreaming(userConfig.Provider) {
1579
            s.logger.Debug(ctx, "No usage information in streaming response (expected - provider doesn't support usage in streaming)", map[string]any{
1580
                "user_prefix":     userPrefix,
1581
                "chunk_count":     chunkCount,
1582
                "content_length":  totalContentLength,
1583
                "provider":        userConfig.Provider,
1584
                "usage_supported": s.supportsUsageInStreaming(userConfig.Provider),
1585
            })
1586
        } else {
1587
            s.logger.Warn(ctx, "No usage information available in streaming response", map[string]any{
1588
                "user_prefix":    userPrefix,
1589
                "chunk_count":    chunkCount,
1590
                "content_length": totalContentLength,
1591
                "provider":       userConfig.Provider,
1592
            })
1593
        }
1594
        span.SetAttributes(attribute.String("stream.result", "no_usage_information"), attribute.Int("chunk_count", chunkCount), attribute.Int("content_length", totalContentLength))
1595
    }
1596

1597
    span.SetAttributes(attribute.String("stream.result", "success"), attribute.Int("chunk_count", chunkCount), attribute.Int("total_content_length", totalContentLength), attribute.String("duration", time.Since(startTime).String()))
1598
    return nil
1599
}
1600

1601
// filterThinkingContent filters out thinking sections for reasoning models
1602
3x
func (s *AIService) filterThinkingContent(content, model string) string {
1603
3x
    // Check if this is a thinking/reasoning model
1604
3x
    if !s.isThinkingModel(model) {
1605
1x
        return content
1606
1x
    }
1607

1608
    // For thinking models, filter out content between <thinking> tags
1609
2x
    if strings.Contains(content, "<thinking>") || strings.Contains(content, "</thinking>") {
1610
        return ""
1611
    }
1612

1613
2x
    if idx := strings.Index(content, "The answer is:"); idx != -1 {
1614
1x
        answer := content[idx+len("The answer is:"):]
1615
1x
        lines := strings.Split(answer, "\n")
1616
1x
        for _, line := range lines {
1617
1x
            trimmed := strings.TrimSpace(line)
1618
1x
            if trimmed != "" {
1619
1x
                return trimmed
1620
1x
            }
1621
        }
1622
        return ""
1623
    }
1624

1625
1x
    trimmed := strings.TrimSpace(content)
1626
1x
    if strings.HasPrefix(trimmed, "I need to") ||
1627
1x
        strings.HasPrefix(trimmed, "Let me think") ||
1628
1x
        strings.HasPrefix(trimmed, "First, I'll") {
1629
        return ""
1630
    }
1631

1632
1x
    return content
1633
}
1634

1635
// isThinkingModel checks if the model is a reasoning/thinking model
1636
9x
func (s *AIService) isThinkingModel(model string) bool {
1637
9x
    thinkingModels := []string{
1638
9x
        "o1-preview",
1639
9x
        "o1-mini",
1640
9x
        "o1",
1641
9x
        "qwen2.5-coder:32b",
1642
9x
        "deepseek-r1",
1643
9x
        "marco-o1",
1644
9x
        "gpt-4",
1645
9x
        "gpt-4-turbo",
1646
9x
        "claude-3",
1647
9x
    }
1648
9x

1649
9x
    modelLower := strings.ToLower(model)
1650
9x
    for _, thinkingModel := range thinkingModels {
1651
73x
        if strings.Contains(modelLower, strings.ToLower(thinkingModel)) {
1652
5x
            return true
1653
5x
        }
1654
    }
1655

1656
4x
    return false
1657
}
1658

1659
// cleanJSONResponse extracts JSON from markdown code blocks or returns the original response
1660
43x
func (s *AIService) cleanJSONResponse(ctx context.Context, response, provider string) string {
1661
43x
    _, span := observability.TraceAIFunction(ctx, "clean_json_response",
1662
43x
        attribute.String("ai.provider", provider),
1663
43x
        attribute.Int("response.length", len(response)),
1664
43x
    )
1665
43x
    defer span.End()
1666
43x
    // If the provider supports grammar field, we expect clean JSON
1667
43x
    if s.supportsGrammarField(provider) {
1668
1x
        return response
1669
1x
    }
1670

1671
    // For providers that don't support grammar field, clean up markdown code blocks
1672
41x
    response = strings.TrimSpace(response)
1673
41x

1674
41x
    // Remove markdown code block markers
1675
41x
    if strings.HasPrefix(response, "```json") {
1676
2x
        response = strings.TrimPrefix(response, "```json")
1677
2x
        response = strings.TrimSuffix(response, "```")
1678
2x
    } else if strings.HasPrefix(response, "```") {
1679
        response = strings.TrimPrefix(response, "```")
1680
1x
        response = strings.TrimSuffix(response, "```")
1681
1x
    }
1682

1683
41x
    return strings.TrimSpace(response)
1684
}
1685

1686
17x
func (s *AIService) parseQuestionsResponse(ctx context.Context, response, language, level string, qType models.QuestionType, provider string) (result0 []*models.Question, err error) {
1687
17x
    if s == nil {
1688
1x
        return nil, contextutils.WrapError(contextutils.ErrInternalError, "AIService instance is nil")
1689
1x
    }
1690
16x
    _, span := observability.TraceAIFunction(ctx, "parse_questions_response",
1691
16x
        observability.AttributeQuestionType(qType),
1692
16x
        observability.AttributeLanguage(language),
1693
16x
        observability.AttributeLevel(level),
1694
16x
        attribute.String("ai.provider", provider),
1695
16x
        attribute.Int("response.length", len(response)),
1696
16x
    )
1697
16x
    defer observability.FinishSpan(span, &err)
1698
16x
    defer func() {
1699
16x
        if r := recover(); r != nil {
1700
            s.logger.Error(ctx, "PANIC in parseQuestionsResponse", nil, map[string]interface{}{
1701
                "panic":    fmt.Sprintf("%v", r),
1702
                "response": response,
1703
                "stack":    string(debug.Stack()),
1704
            })
1705
            span.SetAttributes(attribute.String("parse.result", "panic"), attribute.String("panic", fmt.Sprintf("%v", r)))
1706
        }
1707
    }()
1708

1709
    // Validate input parameters
1710
16x
    if response == "" {
1711
1x
        span.SetAttributes(attribute.String("parse.result", "empty_response"))
1712
1x
        return nil, contextutils.WrapError(contextutils.ErrAIResponseInvalid, "AI provider returned empty response")
1713
1x
    }
1714
15x
    if language == "" {
1715
        span.SetAttributes(attribute.String("parse.result", "empty_language"))
1716
        return nil, contextutils.WrapError(contextutils.ErrAIResponseInvalid, "language cannot be empty")
1717
    }
1718
15x
    if level == "" {
1719
        span.SetAttributes(attribute.String("parse.result", "empty_level"))
1720
        return nil, contextutils.WrapError(contextutils.ErrAIResponseInvalid, "level cannot be empty")
1721
    }
1722

1723
    // Clean the response to handle markdown code blocks for providers without grammar support
1724
15x
    cleanedResponse := s.cleanJSONResponse(ctx, response, provider)
1725
15x

1726
15x
    if cleanedResponse == "" {
1727
        span.SetAttributes(attribute.String("parse.result", "empty_cleaned_response"))
1728
        return nil, contextutils.WrapError(contextutils.ErrAIResponseInvalid, "AI provider returned empty response after cleaning")
1729
    }
1730

1731
    // With grammar field enforcement, we should get clean JSON directly
1732
    // No need for complex extraction - just parse the response directly
1733
15x
    var questions []map[string]interface{}
1734
15x
    if err := json.Unmarshal([]byte(cleanedResponse), &questions); err != nil {
1735
1x
        span.SetAttributes(attribute.String("parse.result", "json_unmarshal_failed"), attribute.String("error", err.Error()))
1736
1x
        return nil, contextutils.WrapErrorf(contextutils.ErrAIResponseInvalid, "failed to parse AI response as JSON: %w", err)
1737
1x
    }
1738

1739
14x
    if len(questions) == 0 {
1740
1x
        span.SetAttributes(attribute.String("parse.result", "no_questions_in_response"))
1741
1x
        return nil, contextutils.WrapError(contextutils.ErrAIResponseInvalid, "AI provider returned no questions in response")
1742
1x
    }
1743

1744
13x
    var result []*models.Question
1745
13x
    var validationErrors []string
1746
13x
    var skippedCount int
1747
13x

1748
13x
    for i, qData := range questions {
1749
1018x
        if qData == nil {
1750
3x
            skippedCount++
1751
3x
            span.SetAttributes(attribute.String("parse.result", "nil_question_data"), attribute.Int("question_index", i))
1752
3x
            continue
1753
        }
1754

1755
1015x
        question, err := s.createQuestionFromData(ctx, qData, language, level, qType)
1756
1015x
        if err != nil {
1757
9x
            // Try to extract more info about the failure
1758
9x
            var failedField, failedValue string
1759
9x
            for k, v := range qData {
1760
55x
                if v == nil || v == "" {
1761
2x
                    failedField = k
1762
2x
                    failedValue = fmt.Sprintf("%v", v)
1763
2x
                    break
1764
                }
1765
            }
1766
9x
            validationErrors = append(validationErrors, fmt.Sprintf("question %d: %v (field: %s, value: %s)", i+1, err, failedField, failedValue))
1767
9x
            span.SetAttributes(attribute.String("parse.result", "question_creation_failed"), attribute.Int("question_index", i), attribute.String("error", err.Error()))
1768
9x
            continue
1769
        }
1770

1771
1006x
        if question == nil {
1772
            skippedCount++
1773
            span.SetAttributes(attribute.String("parse.result", "nil_question_after_creation"), attribute.Int("question_index", i))
1774
            continue
1775
        }
1776

1777
        // Coerce correct_answer to int if it's a float64 (for schema validation)
1778
1006x
        if m := question.Content; m != nil {
1779
1006x
            if v, ok := m["correct_answer"]; ok {
1780
1006x
                switch val := v.(type) {
1781
1006x
                case float64:
1782
1006x
                    m["correct_answer"] = int(val)
1783
                }
1784
            }
1785
        }
1786

1787
1006x
        valid, err := s.ValidateQuestionSchema(ctx, qType, question)
1788
1006x
        if err != nil {
1789
            validationErrors = append(validationErrors, fmt.Sprintf("question %d schema validation error: %v", i+1, err))
1790
            span.SetAttributes(attribute.String("parse.result", "schema_validation_error"), attribute.Int("question_index", i), attribute.String("error", err.Error()))
1791
        }
1792

1793
1006x
        if !valid {
1794
            SchemaValidationMu.Lock()
1795
            SchemaValidationFailures[qType]++
1796
            if err != nil {
1797
                SchemaValidationFailureDetails[qType] = append(SchemaValidationFailureDetails[qType], err.Error())
1798
            } else {
1799
                SchemaValidationFailureDetails[qType] = append(SchemaValidationFailureDetails[qType], "validation failed")
1800
            }
1801
            if len(SchemaValidationFailureDetails[qType]) > 10 {
1802
                SchemaValidationFailureDetails[qType] = SchemaValidationFailureDetails[qType][len(SchemaValidationFailureDetails[qType])-10:]
1803
            }
1804
            SchemaValidationMu.Unlock()
1805
            skippedCount++
1806
            span.SetAttributes(attribute.String("parse.result", "schema_validation_failed"), attribute.Int("question_index", i))
1807
            continue // skip invalid question
1808
        }
1809

1810
1006x
        result = append(result, question)
1811
    }
1812

1813
    // Log validation summary
1814
13x
    if len(validationErrors) > 0 {
1815
8x
        s.logger.Warn(ctx, "AI Service WARNING: validation errors in response", map[string]interface{}{
1816
8x
            "validation_errors_count": len(validationErrors),
1817
8x
            "validation_errors":       strings.Join(validationErrors, "; "),
1818
8x
        })
1819
8x
        span.SetAttributes(attribute.String("parse.result", "validation_errors"), attribute.String("errors", strings.Join(validationErrors, "; ")))
1820
8x
    }
1821

1822
13x
    if len(result) == 0 {
1823
7x
        span.SetAttributes(attribute.String("parse.result", "no_valid_questions"), attribute.Int("total_questions", len(questions)), attribute.Int("skipped_count", skippedCount))
1824
7x
        return nil, contextutils.WrapErrorf(contextutils.ErrAIResponseInvalid, "AI provider returned only invalid or empty questions (total: %d, skipped: %d)", len(questions), skippedCount)
1825
7x
    }
1826

1827
6x
    span.SetAttributes(attribute.String("parse.result", "success"), attribute.Int("valid_questions", len(result)), attribute.Int("total_questions", len(questions)), attribute.Int("skipped_count", skippedCount))
1828
6x
    return result, nil
1829
}
1830

1831
// createQuestionFromData creates a Question from parsed JSON data
1832
2035x
func (s *AIService) createQuestionFromData(ctx context.Context, data map[string]interface{}, language, level string, qType models.QuestionType) (result0 *models.Question, err error) {
1833
2035x
    if s == nil {
1834
1x
        return nil, contextutils.WrapError(contextutils.ErrInternalError, "AIService instance is nil")
1835
1x
    }
1836
2033x
    _, span := observability.TraceAIFunction(ctx, "create_question_from_data",
1837
2033x
        observability.AttributeQuestionType(qType),
1838
2033x
        observability.AttributeLanguage(language),
1839
2033x
        observability.AttributeLevel(level),
1840
2033x
        attribute.Int("data.fields", len(data)),
1841
2033x
    )
1842
2033x
    defer observability.FinishSpan(span, &err)
1843
2033x

1844
2033x
    if data == nil {
1845
1x
        span.SetAttributes(attribute.String("creation.result", "nil_data"))
1846
1x
        return nil, contextutils.WrapError(contextutils.ErrAIResponseInvalid, "question data is nil")
1847
1x
    }
1848

1849
    // Validate required parameters
1850
2031x
    if language == "" {
1851
        span.SetAttributes(attribute.String("creation.result", "empty_language"))
1852
        return nil, contextutils.WrapError(contextutils.ErrAIResponseInvalid, "language cannot be empty")
1853
    }
1854
2031x
    if level == "" {
1855
        span.SetAttributes(attribute.String("creation.result", "empty_level"))
1856
        return nil, contextutils.WrapError(contextutils.ErrAIResponseInvalid, "level cannot be empty")
1857
    }
1858

1859
2031x
    if ok, errMsg := s.validateQuestionContent(ctx, qType, data); !ok {
1860
9x
        missingFields := []string{}
1861
9x
        for k, v := range data {
1862
30x
            if v == nil || v == "" {
1863
2x
                missingFields = append(missingFields, k)
1864
2x
            }
1865
        }
1866
9x
        if len(missingFields) > 0 {
1867
2x
            span.SetAttributes(attribute.String("creation.result", "validation_failed_with_missing_fields"), attribute.String("missing_fields", strings.Join(missingFields, ",")))
1868
2x
            return nil, contextutils.WrapErrorf(contextutils.ErrAIResponseInvalid, "invalid question content structure: %s. Missing or empty fields: %v", errMsg, missingFields)
1869
2x
        }
1870
7x
        span.SetAttributes(attribute.String("creation.result", "validation_failed"), attribute.String("error", errMsg))
1871
7x
        return nil, contextutils.WrapErrorf(contextutils.ErrAIResponseInvalid, "invalid question content structure: %s", errMsg)
1872
    }
1873

1874
    // Defensive: For reading comprehension, check passage, question, options, correct_answer
1875
2013x
    if qType == models.ReadingComprehension {
1876
1x
        if _, ok := data["passage"].(string); !ok {
1877
            span.SetAttributes(attribute.String("creation.result", "reading_missing_passage"))
1878
            return nil, contextutils.WrapError(contextutils.ErrAIResponseInvalid, "reading comprehension question missing or invalid 'passage' field")
1879
        }
1880
1x
        if _, ok := data["question"].(string); !ok {
1881
            span.SetAttributes(attribute.String("creation.result", "reading_missing_question"))
1882
            return nil, contextutils.WrapError(contextutils.ErrAIResponseInvalid, "reading comprehension question missing or invalid 'question' field")
1883
        }
1884
1x
        options, ok := data["options"].([]interface{})
1885
1x
        if !ok || len(options) != 4 {
1886
            span.SetAttributes(attribute.String("creation.result", "reading_invalid_options"))
1887
            return nil, contextutils.WrapError(contextutils.ErrAIResponseInvalid, "reading comprehension question missing or invalid 'options' field (must be array of 4 strings)")
1888
        }
1889
1x
        for i, opt := range options {
1890
4x
            if _, ok := opt.(string); !ok {
1891
                span.SetAttributes(attribute.String("creation.result", "reading_invalid_option_type"), attribute.Int("option_index", i))
1892
                return nil, contextutils.WrapErrorf(contextutils.ErrAIResponseInvalid, "reading comprehension question 'options' must be array of strings, found invalid type at index %d", i)
1893
            }
1894
        }
1895
1x
        if _, ok := data["correct_answer"]; !ok {
1896
            span.SetAttributes(attribute.String("creation.result", "reading_missing_correct_answer"))
1897
            return nil, contextutils.WrapError(contextutils.ErrAIResponseInvalid, "reading comprehension question missing 'correct_answer' field")
1898
        }
1899
    }
1900

1901
    // Parse correct_answer as index (integer)
1902
2013x
    var correctAnswerIndex int
1903
2013x
    if correctAnswerRaw, exists := data["correct_answer"]; exists {
1904
2013x
        switch v := correctAnswerRaw.(type) {
1905
        case int:
1906
            correctAnswerIndex = v
1907
2013x
        case float64:
1908
2013x
            correctAnswerIndex = int(v)
1909
        case string:
1910
            // Handle string indices like "0", "1", "2", "3"
1911
            if idx, err := strconv.Atoi(v); err == nil {
1912
                correctAnswerIndex = idx
1913
            } else {
1914
                // Handle answer text - find index in options
1915
                if options, ok := data["options"].([]interface{}); ok {
1916
                    found := false
1917
                    for i, opt := range options {
1918
                        if optStr, ok := opt.(string); ok && optStr == v {
1919
                            correctAnswerIndex = i
1920
                            found = true
1921
                            break
1922
                        }
1923
                    }
1924
                    if !found {
1925
                        span.SetAttributes(attribute.String("creation.result", "correct_answer_not_found_in_options"))
1926
                        return nil, contextutils.WrapErrorf(contextutils.ErrAIResponseInvalid, "correct_answer '%s' not found in options", v)
1927
                    }
1928
                } else {
1929
                    span.SetAttributes(attribute.String("creation.result", "no_options_for_text_answer"))
1930
                    return nil, contextutils.WrapErrorf(contextutils.ErrAIResponseInvalid, "correct_answer is text '%s' but no options available to match against", v)
1931
                }
1932
            }
1933
        default:
1934
            span.SetAttributes(attribute.String("creation.result", "invalid_correct_answer_type"), attribute.String("type", fmt.Sprintf("%T", v)))
1935
            return nil, contextutils.WrapErrorf(contextutils.ErrAIResponseInvalid, "invalid correct_answer type: %T", v)
1936
        }
1937
    } else {
1938
        span.SetAttributes(attribute.String("creation.result", "missing_correct_answer"))
1939
        return nil, contextutils.WrapError(contextutils.ErrAIResponseInvalid, "missing correct_answer field")
1940
    }
1941

1942
    // Validate correct answer index
1943
2013x
    if options, ok := data["options"].([]interface{}); ok {
1944
2013x
        if correctAnswerIndex < 0 || correctAnswerIndex >= len(options) {
1945
            span.SetAttributes(attribute.String("creation.result", "invalid_correct_answer_index"), attribute.Int("index", correctAnswerIndex), attribute.Int("options_count", len(options)))
1946
            return nil, contextutils.WrapErrorf(contextutils.ErrAIResponseInvalid, "correct_answer index %d is out of range (0-%d)", correctAnswerIndex, len(options)-1)
1947
        }
1948
    }
1949

1950
    // Note: Removed backend shuffling logic - frontend handles shuffling
1951
    // This prevents mismatch between backend and frontend answer indices
1952

1953
    // Get explanation or provide default
1954
2013x
    explanation, _ := data["explanation"].(string)
1955
2013x
    if explanation == "" {
1956
1005x
        // Provide a default explanation based on question type
1957
1005x
        switch qType {
1958
1005x
        case models.Vocabulary:
1959
1005x
            explanation = "This vocabulary question tests your knowledge of words in context."
1960
        case models.ReadingComprehension:
1961
            explanation = "This reading comprehension question tests your understanding of the passage."
1962
        case models.FillInBlank:
1963
            explanation = "This fill-in-the-blank question tests your grammar and vocabulary knowledge."
1964
        case models.QuestionAnswer:
1965
            explanation = "This question tests your conversational and practical language skills."
1966
        default:
1967
            explanation = "This question tests your language skills."
1968
        }
1969
        // Add the explanation to the data for schema validation
1970
1005x
        data["explanation"] = explanation
1971
    }
1972

1973
2013x
    question := &models.Question{
1974
2013x
        Type:            qType,
1975
2013x
        Language:        language,
1976
2013x
        Level:           level,
1977
2013x
        DifficultyScore: s.getDifficultyScore(level),
1978
2013x
        Content:         data,
1979
2013x
        CorrectAnswer:   correctAnswerIndex,
1980
2013x
        Explanation:     explanation,
1981
2013x
        CreatedAt:       time.Now(),
1982
2013x
    }
1983
2013x

1984
2013x
    span.SetAttributes(attribute.String("creation.result", "success"))
1985
2013x
    return question, nil
1986
}
1987

1988
3x
func (s *AIService) parseQuestionResponse(ctx context.Context, response, language, level string, qType models.QuestionType, provider string) (result0 *models.Question, err error) {
1989
3x
    _, span := observability.TraceAIFunction(ctx, "parse_question_response",
1990
3x
        observability.AttributeQuestionType(qType),
1991
3x
        observability.AttributeLanguage(language),
1992
3x
        observability.AttributeLevel(level),
1993
3x
        attribute.String("ai.provider", provider),
1994
3x
        attribute.Int("response.length", len(response)),
1995
3x
    )
1996
3x
    defer observability.FinishSpan(span, &err)
1997
3x
    // Clean the response to handle markdown code blocks for providers without grammar support
1998
3x
    cleanedResponse := s.cleanJSONResponse(ctx, response, provider)
1999
3x

2000
3x
    // With grammar field enforcement, we should get clean JSON directly
2001
3x
    // No need for complex extraction - just parse the response directly
2002
3x
    var data map[string]interface{}
2003
3x
    if err := json.Unmarshal([]byte(cleanedResponse), &data); err != nil {
2004
2x
        s.logger.Error(ctx, "Failed to parse JSON response", err, map[string]interface{}{
2005
2x
            "raw_response": response,
2006
2x
        })
2007
2x
        return nil, contextutils.WrapErrorf(contextutils.ErrAIResponseInvalid, "failed to parse AI response as JSON: %w", err)
2008
2x
    }
2009

2010
1x
    question, err := s.createQuestionFromData(ctx, data, language, level, qType)
2011
1x
    if err != nil {
2012
        s.logger.Error(ctx, "Failed to create question from data", err, map[string]interface{}{
2013
            "raw_question_data":   data,
2014
            "full_model_response": response,
2015
        })
2016
        return nil, contextutils.WrapErrorf(contextutils.ErrAIResponseInvalid, "failed to create question: %w", err)
2017
    }
2018
1x
    valid, err := s.ValidateQuestionSchema(ctx, qType, question)
2019
1x
    if err != nil {
2020
        s.logger.Error(ctx, "Schema validation error for question", err, nil)
2021
    }
2022
1x
    if !valid {
2023
        SchemaValidationMu.Lock()
2024
        SchemaValidationFailures[qType]++
2025
        if err != nil {
2026
            SchemaValidationFailureDetails[qType] = append(SchemaValidationFailureDetails[qType], err.Error())
2027
        } else {
2028
            SchemaValidationFailureDetails[qType] = append(SchemaValidationFailureDetails[qType], "validation failed")
2029
        }
2030
        if len(SchemaValidationFailureDetails[qType]) > 10 {
2031
            SchemaValidationFailureDetails[qType] = SchemaValidationFailureDetails[qType][len(SchemaValidationFailureDetails[qType])-10:]
2032
        }
2033
        SchemaValidationMu.Unlock()
2034
    }
2035
1x
    return question, nil
2036
}
2037

2038
2061x
func (s *AIService) getDifficultyScore(level string) float64 {
2039
2061x
    // Look up the level in the language levels configuration
2040
2061x
    if s.cfg != nil && s.cfg.LanguageLevels != nil {
2041
24x
        for _, langConfig := range s.cfg.LanguageLevels {
2042
89x
            for i, lvl := range langConfig.Levels {
2043
392x
                if lvl == level {
2044
21x
                    // Return a score based on the level's position (0.0 to 1.0)
2045
21x
                    return float64(i) / float64(len(langConfig.Levels)-1)
2046
21x
                }
2047
            }
2048
        }
2049
    }
2050
    // Default to middle difficulty if level not found
2051
2019x
    return 0.5
2052
}
2053

2054
2031x
func (s *AIService) validateQuestionContent(ctx context.Context, qType models.QuestionType, content map[string]interface{}) (bool, string) {
2055
2031x
    _, span := observability.TraceAIFunction(ctx, "validate_question_content",
2056
2031x
        observability.AttributeQuestionType(qType),
2057
2031x
        attribute.Int("content.fields", len(content)),
2058
2031x
    )
2059
2031x
    defer span.End()
2060
2031x

2061
2031x
    // Validate input parameters
2062
2031x
    if content == nil {
2063
        span.SetAttributes(attribute.String("validation.result", "nil_content"))
2064
        return false, "question content cannot be nil"
2065
    }
2066

2067
2031x
    requiredFields := make(map[string]func(interface{}) bool)
2068
2031x
    isString := func(v interface{}) bool {
2069
4058x
        if v == nil {
2070
1x
            return false
2071
1x
        }
2072
4056x
        _, ok := v.(string)
2073
4056x
        return ok && v.(string) != ""
2074
    }
2075
2031x
    isStringSlice := func(v interface{}) bool {
2076
2027x
        if v == nil {
2077
2x
            return false
2078
2x
        }
2079
2023x
        if slice, ok := v.([]interface{}); ok {
2080
2023x
            if len(slice) < 4 {
2081
1x
                return false
2082
1x
            }
2083
2021x
            for _, item := range slice {
2084
8084x
                if item == nil {
2085
                    return false
2086
                }
2087
8084x
                if _, ok := item.(string); !ok {
2088
                    return false
2089
                }
2090
8084x
                if item.(string) == "" {
2091
                    return false
2092
                }
2093
            }
2094
2021x
            return true
2095
        }
2096
        return false
2097
    }
2098
2031x
    isCorrectAnswer := func(v interface{}) bool {
2099
1x
        if v == nil {
2100
            return false
2101
        }
2102
1x
        switch val := v.(type) {
2103
        case int:
2104
            return val >= 0
2105
1x
        case float64:
2106
1x
            return val >= 0 && val == float64(int(val)) // Must be whole number
2107
        case string:
2108
            // Accept string indices like "0", "1", "2", "3" or answer text
2109
            if _, err := strconv.Atoi(val); err == nil {
2110
                return true
2111
            }
2112
            // Or accept answer text that matches one of the options
2113
            if options, ok := content["options"].([]interface{}); ok {
2114
                for _, opt := range options {
2115
                    if optStr, ok := opt.(string); ok && optStr == val {
2116
                        return true
2117
                    }
2118
                }
2119
            }
2120
            return false
2121
        default:
2122
            return false
2123
        }
2124
    }
2125

2126
2031x
    switch qType {
2127
2029x
    case models.Vocabulary:
2128
2029x
        requiredFields["sentence"] = isString
2129
2029x
        requiredFields["question"] = isString
2130
2029x
        requiredFields["options"] = isStringSlice
2131
2029x
        for field, validator := range requiredFields {
2132
6079x
            if !validator(content[field]) {
2133
5x
                span.SetAttributes(attribute.String("validation.result", "field_validation_failed"), attribute.String("field", field))
2134
5x
                return false, fmt.Sprintf("[Vocabulary] Validation failed for field '%s': %v", field, content[field])
2135
5x
            }
2136
        }
2137
2019x
        sentence, _ := content["sentence"].(string)
2138
2019x
        targetWord, _ := content["question"].(string)
2139
2019x
        options, _ := content["options"].([]interface{})
2140
2019x
        if sentence == "" || targetWord == "" || len(options) != 4 {
2141
            span.SetAttributes(attribute.String("validation.result", "vocabulary_structure_failed"))
2142
            return false, "[Vocabulary] Validation failed: missing or invalid sentence/question/options"
2143
        }
2144
2019x
        if !strings.Contains(sentence, targetWord) {
2145
4x
            span.SetAttributes(attribute.String("validation.result", "vocabulary_word_not_found"))
2146
4x
            return false, fmt.Sprintf("[Vocabulary] Validation failed: question '%s' not found in sentence '%s'", targetWord, sentence)
2147
4x
        }
2148
2011x
        span.SetAttributes(attribute.String("validation.result", "valid"))
2149
2011x
        return true, ""
2150

2151
1x
    case models.ReadingComprehension:
2152
1x
        requiredFields["passage"] = isString
2153
1x
        requiredFields["question"] = isString
2154
1x
        requiredFields["options"] = isStringSlice
2155
1x
        requiredFields["correct_answer"] = isCorrectAnswer
2156
1x
        for field, validator := range requiredFields {
2157
4x
            if !validator(content[field]) {
2158
                span.SetAttributes(attribute.String("validation.result", "field_validation_failed"), attribute.String("field", field))
2159
                return false, fmt.Sprintf("[ReadingComprehension] Validation failed for field '%s': %v", field, content[field])
2160
            }
2161
        }
2162
1x
        passage, _ := content["passage"].(string)
2163
1x
        if passage == "" {
2164
            span.SetAttributes(attribute.String("validation.result", "reading_passage_empty"))
2165
            return false, "[ReadingComprehension] Validation failed: passage cannot be empty"
2166
        }
2167
1x
        span.SetAttributes(attribute.String("validation.result", "valid"))
2168
1x
        return true, ""
2169

2170
    case models.FillInBlank:
2171
        // Fill-in-blank questions now use multiple choice format like all other types
2172
        requiredFields["question"] = isString
2173
        requiredFields["options"] = isStringSlice
2174
        requiredFields["correct_answer"] = isCorrectAnswer
2175
        for field, validator := range requiredFields {
2176
            if !validator(content[field]) {
2177
                span.SetAttributes(attribute.String("validation.result", "field_validation_failed"), attribute.String("field", field))
2178
                return false, fmt.Sprintf("[FillInBlank] Validation failed for field '%s': %v", field, content[field])
2179
            }
2180
        }
2181
        span.SetAttributes(attribute.String("validation.result", "valid"))
2182
        return true, ""
2183

2184
    case models.QuestionAnswer:
2185
        // Question-answer questions now use multiple choice format like all other types
2186
        requiredFields["question"] = isString
2187
        requiredFields["options"] = isStringSlice
2188
        requiredFields["correct_answer"] = isCorrectAnswer
2189
        for field, validator := range requiredFields {
2190
            if !validator(content[field]) {
2191
                span.SetAttributes(attribute.String("validation.result", "field_validation_failed"), attribute.String("field", field))
2192
                return false, fmt.Sprintf("[QuestionAnswer] Validation failed for field '%s': %v", field, content[field])
2193
            }
2194
        }
2195
        span.SetAttributes(attribute.String("validation.result", "valid"))
2196
        return true, ""
2197
    }
2198

2199
    // If we reach here, it's an unknown question type
2200
    span.SetAttributes(attribute.String("validation.result", "unknown_type"))
2201
    return false, fmt.Sprintf("unknown question type: %v", qType)
2202
}
2203

2204
// GetConcurrencyStats returns current concurrency metrics
2205
9x
func (s *AIService) GetConcurrencyStats() ConcurrencyStats {
2206
9x
    s.statsMu.RLock()
2207
9x
    s.concurrencyMu.RLock()
2208
9x
    defer s.statsMu.RUnlock()
2209
9x
    defer s.concurrencyMu.RUnlock()
2210
9x

2211
9x
    // Count active requests globally and per user
2212
9x
    queuedRequests := 0 // Currently we don't queue, we fail fast
2213
9x

2214
9x
    userActiveCount := make(map[string]int)
2215
9x
    for username, count := range s.userRequestCount {
2216
11x
        if count > 0 {
2217
1x
            userActiveCount[username] = count
2218
1x
        }
2219
    }
2220

2221
9x
    return ConcurrencyStats{
2222
9x
        ActiveRequests:  s.activeRequests,
2223
9x
        MaxConcurrent:   s.maxConcurrent,
2224
9x
        QueuedRequests:  queuedRequests,
2225
9x
        TotalRequests:   s.totalRequests,
2226
9x
        UserActiveCount: userActiveCount,
2227
9x
        MaxPerUser:      s.maxPerUser,
2228
9x
    }
2229
}
2230

2231
// acquireGlobalSlot attempts to acquire a global concurrency slot
2232
15x
func (s *AIService) acquireGlobalSlot(ctx context.Context) error {
2233
15x
    select {
2234
13x
    case s.globalSemaphore <- struct{}{}:
2235
13x
        return nil
2236
    case <-ctx.Done():
2237
        return contextutils.WrapErrorf(contextutils.ErrTimeout, "request cancelled while waiting for global AI slot: %w", ctx.Err())
2238
1x
    default:
2239
1x
        return contextutils.WrapErrorf(contextutils.ErrServiceUnavailable, "AI service at capacity (%d concurrent requests), please try again", s.maxConcurrent)
2240
    }
2241
}
2242

2243
// releaseGlobalSlot releases a global concurrency slot
2244
13x
func (s *AIService) releaseGlobalSlot(ctx context.Context) {
2245
13x
    s.concurrencyMu.Lock()
2246
13x
    defer s.concurrencyMu.Unlock()
2247
13x

2248
13x
    select {
2249
13x
    case <-s.globalSemaphore:
2250
13x
        // Successfully released a slot
2251
13x
        s.statsMu.Lock()
2252
13x
        if s.activeRequests > 0 {
2253
7x
            s.activeRequests--
2254
7x
        }
2255
13x
        s.statsMu.Unlock()
2256
    default:
2257
        // No slot was acquired
2258
        s.logger.Warn(ctx, "WARNING: Attempted to release global AI slot but none were acquired", nil)
2259
    }
2260
}
2261

2262
// acquireUserSlot acquires a user-specific concurrency slot
2263
17x
func (s *AIService) acquireUserSlot(_ context.Context, username string) error {
2264
17x
    s.concurrencyMu.Lock()
2265
17x
    defer s.concurrencyMu.Unlock()
2266
17x

2267
17x
    currentCount := s.userRequestCount[username]
2268
17x
    if currentCount >= s.maxPerUser {
2269
1x
        return contextutils.WrapErrorf(contextutils.ErrServiceUnavailable, "user concurrency limit exceeded for %s: %d/%d", username, currentCount, s.maxPerUser)
2270
1x
    }
2271

2272
15x
    s.userRequestCount[username] = currentCount + 1
2273
15x
    return nil
2274
}
2275

2276
// releaseUserSlot releases a user-specific concurrency slot
2277
15x
func (s *AIService) releaseUserSlot(ctx context.Context, username string) {
2278
15x
    s.concurrencyMu.Lock()
2279
15x
    defer s.concurrencyMu.Unlock()
2280
15x

2281
15x
    currentCount := s.userRequestCount[username]
2282
15x
    if currentCount > 0 {
2283
15x
        s.userRequestCount[username] = currentCount - 1
2284
15x
    } else {
2285
        s.logger.Warn(ctx, "WARNING: Attempted to release user AI slot but none were acquired", map[string]interface{}{
2286
            "username": username,
2287
        })
2288
    }
2289
}
2290

2291
// incrementTotalRequests increments the total request counter
2292
7x
func (s *AIService) incrementTotalRequests() {
2293
7x
    s.statsMu.Lock()
2294
7x
    defer s.statsMu.Unlock()
2295
7x
    s.totalRequests++
2296
7x
}
2297

2298
// withConcurrencyControl wraps an AI operation with concurrency limits
2299
9x
func (s *AIService) withConcurrencyControl(ctx context.Context, username string, operation func() error) error {
2300
9x
    // Check if service is shutting down
2301
9x
    if s.isShutdown() {
2302
1x
        return contextutils.WrapError(contextutils.ErrServiceUnavailable, "AI service is shutting down")
2303
1x
    }
2304

2305
    // Increment total request counter
2306
7x
    s.incrementTotalRequests()
2307
7x

2308
7x
    // Acquire global slot
2309
7x
    if err := s.acquireGlobalSlot(ctx); err != nil {
2310
        return err
2311
    }
2312

2313
    // Track active request
2314
7x
    s.statsMu.Lock()
2315
7x
    s.activeRequests++
2316
7x
    s.statsMu.Unlock()
2317
7x

2318
7x
    defer func() {
2319
7x
        s.releaseGlobalSlot(ctx)
2320
7x
    }()
2321

2322
    // Acquire per-user slot
2323
7x
    if err := s.acquireUserSlot(ctx, username); err != nil {
2324
        return err
2325
    }
2326
7x
    defer s.releaseUserSlot(ctx, username)
2327
7x

2328
7x
    // Execute the actual operation
2329
7x
    return operation()
2330
}
2331

2332
// supportsGrammarField checks if the provider supports the grammar field
2333
78x
func (s *AIService) supportsGrammarField(provider string) bool {
2334
78x
    // Check if the provider supports grammar field
2335
78x
    if s.cfg.Providers == nil {
2336
19x
        return false
2337
19x
    }
2338

2339
40x
    for _, providerConfig := range s.cfg.Providers {
2340
83x
        if providerConfig.Code == provider {
2341
33x
            return providerConfig.SupportsGrammar
2342
33x
        }
2343
    }
2344
7x
    return false
2345
}
2346

2347
// supportsUsageInStreaming checks if the provider supports usage tracking in streaming responses
2348
func (s *AIService) supportsUsageInStreaming(provider string) bool {
2349
    for _, providerConfig := range s.cfg.Providers {
2350
        if providerConfig.Code == provider {
2351
            return providerConfig.UsageSupported
2352
        }
2353
    }
2354
    return true
2355
}
2356

2357
// getQuestionBatchSize returns the maximum number of questions that can be generated in a single request for the given provider
2358
6x
func (s *AIService) getQuestionBatchSize(provider string) int {
2359
6x
    // Get the batch size for the provider
2360
6x
    if s.cfg.Providers == nil {
2361
        return 1 // Default batch size
2362
    }
2363

2364
6x
    for _, p := range s.cfg.Providers {
2365
11x
        if p.Code == provider {
2366
3x
            if p.QuestionBatchSize > 0 {
2367
3x
                return p.QuestionBatchSize
2368
3x
            }
2369
            break
2370
        }
2371
    }
2372
3x
    return 1 // Default batch size
2373
}
2374

2375
// GetQuestionBatchSize returns the maximum number of questions that can be generated in a single request for the given provider
2376
func (s *AIService) GetQuestionBatchSize(provider string) int {
2377
    return s.getQuestionBatchSize(provider)
2378
}
2379

2380
// VarietyService returns the variety service used by the AI service
2381
1x
func (s *AIService) VarietyService() *VarietyService {
2382
1x
    return s.varietyService
2383
1x
}
2384

2385
// TemplateManager exposes template rendering and example loading for prompts
2386
func (s *AIService) TemplateManager() *AITemplateManager {
2387
    return s.templateManager
2388
}
2389

2390
// SupportsGrammarField reports whether the provider supports the grammar field
2391
func (s *AIService) SupportsGrammarField(provider string) bool {
2392
    return s.supportsGrammarField(provider)
2393
}
2394

2395
// CallWithPrompt sends a raw prompt (and optional grammar) to the provider and returns the response
2396
func (s *AIService) CallWithPrompt(ctx context.Context, userConfig *models.UserAIConfig, prompt, grammar string) (string, error) {
2397
    return s.callOpenAI(ctx, userConfig, prompt, grammar)
2398
}
2399

2400
// trackAIUsage tracks AI usage statistics
2401
4x
func (s *AIService) trackAIUsage(ctx context.Context, userConfig *models.UserAIConfig, usage Usage, userID int, apiKeyID *int) {
2402
4x
    // Skip recording if userID is invalid (0 means no user context)
2403
4x
    if userID == 0 {
2404
1x
        s.logger.Error(ctx, "Skipping AI usage tracking - no valid user ID in context", nil, map[string]interface{}{
2405
1x
            "provider":          userConfig.Provider,
2406
1x
            "model":             userConfig.Model,
2407
1x
            "prompt_tokens":     usage.PromptTokens,
2408
1x
            "completion_tokens": usage.CompletionTokens,
2409
1x
            "total_tokens":      usage.TotalTokens,
2410
1x
        })
2411
1x
        return
2412
1x
    }
2413

2414
    // TODO: Determine usage type based on the context (this is a simple heuristic)
2415
3x
    usageType := "generic" // Default assumption
2416
3x

2417
3x
    // Record usage in the usage stats service
2418
3x
    err := s.usageStatsSvc.RecordUserAITokenUsage(
2419
3x
        ctx,
2420
3x
        userID,
2421
3x
        apiKeyID,
2422
3x
        userConfig.Provider,
2423
3x
        userConfig.Model,
2424
3x
        usageType,
2425
3x
        usage.PromptTokens,
2426
3x
        usage.CompletionTokens,
2427
3x
        usage.TotalTokens,
2428
3x
        1, // requests
2429
3x
    )
2430
3x
    if err != nil {
2431
1x
        s.logger.Warn(ctx, "Failed to record AI usage", map[string]interface{}{
2432
1x
            "error":   err.Error(),
2433
1x
            "user_id": userID,
2434
1x
        })
2435
1x
    }
2436
}
2437


			
quizapp internal services worker_service.go
78.6%
Statements
11/14
1
// Package services provides embedded templates for AI service prompts
2
package services
3

4
import (
5
    "embed"
6
    "fmt"
7
    "strings"
8
    "text/template"
9

10
    contextutils "quizapp/internal/utils"
11
)
12

13
//go:embed templates/*.tmpl
14
var aiTemplatesFS embed.FS
15

16
//go:embed templates/examples/*.json
17
var exampleFilesFS embed.FS
18

19
// Template names as constants
20
const (
21
    BatchQuestionPromptTemplate   = "batch_question_prompt.tmpl"
22
    ChatPromptTemplate            = "chat_prompt.tmpl"
23
    JSONStructureGuidanceTemplate = "json_structure_guidance.tmpl"
24
    AIFixPromptTemplate           = "ai_fix_prompt.tmpl"
25
)
26

27
// AITemplateData holds data for rendering AI prompt templates
28
type AITemplateData struct {
29
    // Common fields
30
    Language              string
31
    Level                 string
32
    QuestionType          string
33
    Topic                 string
34
    RecentQuestionHistory []string
35
    ReportReasons         []string
36
    Count                 int // For batch generation
37

38
    // Variety fields for question generation
39
    TopicCategory      string
40
    GrammarFocus       string
41
    VocabularyDomain   string
42
    Scenario           string
43
    StyleModifier      string
44
    DifficultyModifier string
45
    TimeContext        string
46

47
    // Schema and formatting
48
    SchemaForPrompt     string // for direct inclusion in prompt for non-grammar providers
49
    ExampleContent      string // for including example in prompt
50
    CurrentQuestionJSON string // the actual question JSON to pass into ai-fix prompt
51
    AdditionalContext   string // optional freeform context provided by admin when requesting AI fix
52

53
    // Explanation specific
54
    Question      string
55
    UserAnswer    string
56
    CorrectAnswer string // The text of the correct answer for explanations
57

58
    // Chat specific
59
    Passage             string
60
    Options             []string
61
    IsCorrect           *bool
62
    ConversationHistory []ChatMessage
63
    UserMessage         string
64

65
    // Priority-aware generation fields (NEW)
66
    UserWeakAreas        []string
67
    HighPriorityTopics   []string
68
    GapAnalysis          map[string]int
69
    FocusOnWeakAreas     bool
70
    FreshQuestionRatio   float64
71
    PriorityDistribution map[string]int
72

73
    // Story generation fields
74
    Title              string
75
    Subject            string
76
    AuthorStyle        string
77
    TimePeriod         string
78
    Genre              string
79
    Tone               string
80
    CharacterNames     string
81
    CustomInstructions string
82
    TargetWords        int
83
    TargetSentences    int
84
    IsFirstSection     bool
85
    PreviousSections   string
86
    SectionText        string
87
}
88

89
// ChatMessage represents a chat message for templates
90
type ChatMessage struct {
91
    Role    string
92
    Content string
93
}
94

95
// AITemplateManager manages AI prompt templates
96
type AITemplateManager struct {
97
    templates *template.Template
98
}
99

100
// NewAITemplateManager creates a new template manager
101
70x
func NewAITemplateManager() (result0 *AITemplateManager, err error) {
102
70x
    templates, err := template.New("").ParseFS(aiTemplatesFS, "templates/*.tmpl")
103
70x
    if err != nil {
104
        return nil, err
105
    }
106

107
70x
    return &AITemplateManager{
108
70x
        templates: templates,
109
70x
    }, nil
110
}
111

112
// RenderTemplate renders a template with the given data
113
42x
func (tm *AITemplateManager) RenderTemplate(templateName string, data AITemplateData) (result0 string, err error) {
114
42x
    var buf strings.Builder
115
42x
    err = tm.templates.ExecuteTemplate(&buf, templateName, data)
116
42x
    if err != nil {
117
        return "", err
118
    }
119
42x
    return buf.String(), nil
120
}
121

122
// LoadExample loads the example JSON for a specific question type
123
19x
func (tm *AITemplateManager) LoadExample(questionType string) (result0 string, err error) {
124
19x
    examplePath := fmt.Sprintf("templates/examples/%s_example.json", questionType)
125
19x
    content, err := exampleFilesFS.ReadFile(examplePath)
126
19x
    if err != nil {
127
        return "", contextutils.WrapErrorf(contextutils.ErrInternalError, "failed to load example for %s: %w", questionType, err)
128
    }
129
19x
    return string(content), nil
130
}
131


			
quizapp internal services worker_service.go
80.5%
Statements
132/164
1
package services
2

3
import (
4
    "context"
5
    "crypto/rand"
6
    "database/sql"
7
    "encoding/hex"
8
    "errors"
9
    "time"
10

11
    "quizapp/internal/models"
12
    "quizapp/internal/observability"
13
    contextutils "quizapp/internal/utils"
14

15
    "go.opentelemetry.io/otel/attribute"
16
    "go.opentelemetry.io/otel/codes"
17
    "golang.org/x/crypto/bcrypt"
18
)
19

20
// AuthAPIKeyServiceInterface defines the interface for auth API key operations
21
type AuthAPIKeyServiceInterface interface {
22
    CreateAPIKey(ctx context.Context, userID int, keyName, permissionLevel string) (*models.AuthAPIKey, string, error)
23
    ListAPIKeys(ctx context.Context, userID int) ([]models.AuthAPIKey, error)
24
    GetAPIKeyByID(ctx context.Context, userID, keyID int) (*models.AuthAPIKey, error)
25
    DeleteAPIKey(ctx context.Context, userID, keyID int) error
26
    ValidateAPIKey(ctx context.Context, rawKey string) (*models.AuthAPIKey, error)
27
    UpdateLastUsed(ctx context.Context, keyID int) error
28
}
29

30
// AuthAPIKeyService implements AuthAPIKeyServiceInterface
31
type AuthAPIKeyService struct {
32
    db     *sql.DB
33
    logger *observability.Logger
34
}
35

36
// NewAuthAPIKeyService creates a new AuthAPIKeyService instance
37
3x
func NewAuthAPIKeyService(db *sql.DB, logger *observability.Logger) *AuthAPIKeyService {
38
3x
    return &AuthAPIKeyService{
39
3x
        db:     db,
40
3x
        logger: logger,
41
3x
    }
42
3x
}
43

44
const (
45
    // KeyPrefix is the prefix for all auth API keys
46
    KeyPrefix = "qapp_"
47
    // KeyLength is the length of the random part of the key (32 characters)
48
    KeyLength = 32
49
)
50

51
// generateAPIKey generates a new random API key
52
1x
func generateAPIKey() (string, error) {
53
1x
    // Generate 32 random bytes
54
1x
    randomBytes := make([]byte, KeyLength/2) // 16 bytes = 32 hex characters
55
1x
    if _, err := rand.Read(randomBytes); err != nil {
56
        return "", contextutils.WrapErrorf(err, "failed to generate random key: %w", err)
57
    }
58

59
    // Convert to hex string
60
1x
    randomStr := hex.EncodeToString(randomBytes)
61
1x

62
1x
    // Add prefix
63
1x
    return KeyPrefix + randomStr, nil
64
}
65

66
// hashAPIKey hashes an API key using bcrypt
67
1x
func hashAPIKey(key string) (string, error) {
68
1x
    hash, err := bcrypt.GenerateFromPassword([]byte(key), bcrypt.DefaultCost)
69
1x
    if err != nil {
70
        return "", contextutils.WrapErrorf(err, "failed to hash API key: %w", err)
71
    }
72
1x
    return string(hash), nil
73
}
74

75
// CreateAPIKey creates a new API key for a user
76
3x
func (s *AuthAPIKeyService) CreateAPIKey(ctx context.Context, userID int, keyName, permissionLevel string) (*models.AuthAPIKey, string, error) {
77
3x
    ctx, span := observability.TraceFunction(ctx, "auth_api_key_service", "create_api_key")
78
3x
    defer observability.FinishSpan(span, nil)
79
3x

80
3x
    span.SetAttributes(
81
3x
        attribute.Int("user_id", userID),
82
3x
        attribute.String("key_name", keyName),
83
3x
        attribute.String("permission_level", permissionLevel),
84
3x
    )
85
3x

86
3x
    // Validate permission level
87
3x
    if !models.IsValidPermissionLevel(permissionLevel) {
88
1x
        err := contextutils.NewAppError(
89
1x
            contextutils.ErrorCodeInvalidInput,
90
1x
            contextutils.SeverityWarn,
91
1x
            "Invalid permission level",
92
1x
            "Permission level must be 'readonly' or 'full'",
93
1x
        )
94
1x
        span.RecordError(err)
95
1x
        span.SetStatus(codes.Error, err.Error())
96
1x
        return nil, "", err
97
1x
    }
98

99
    // Validate key name
100
2x
    if keyName == "" {
101
1x
        err := contextutils.NewAppError(
102
1x
            contextutils.ErrorCodeInvalidInput,
103
1x
            contextutils.SeverityWarn,
104
1x
            "Key name is required",
105
1x
            "",
106
1x
        )
107
1x
        span.RecordError(err)
108
1x
        span.SetStatus(codes.Error, err.Error())
109
1x
        return nil, "", err
110
1x
    }
111

112
    // Generate new API key
113
1x
    rawKey, err := generateAPIKey()
114
1x
    if err != nil {
115
        span.RecordError(err)
116
        span.SetStatus(codes.Error, "failed to generate API key")
117
        return nil, "", contextutils.WrapError(err, "failed to generate API key")
118
    }
119

120
    // Hash the key
121
1x
    keyHash, err := hashAPIKey(rawKey)
122
1x
    if err != nil {
123
        span.RecordError(err)
124
        span.SetStatus(codes.Error, "failed to hash API key")
125
        return nil, "", contextutils.WrapError(err, "failed to hash API key")
126
    }
127

128
    // Extract key prefix (first 12 characters including "qapp_")
129
1x
    keyPrefix := rawKey
130
1x
    if len(rawKey) > 12 {
131
1x
        keyPrefix = rawKey[:12]
132
1x
    }
133

134
    // Insert into database
135
1x
    query := `
136
1x
        INSERT INTO auth_api_keys (user_id, key_name, key_hash, key_prefix, permission_level, created_at, updated_at)
137
1x
        VALUES ($1, $2, $3, $4, $5, $6, $7)
138
1x
        RETURNING id, created_at, updated_at
139
1x
    `
140
1x

141
1x
    now := time.Now()
142
1x
    var apiKey models.AuthAPIKey
143
1x
    apiKey.UserID = userID
144
1x
    apiKey.KeyName = keyName
145
1x
    apiKey.KeyHash = keyHash
146
1x
    apiKey.KeyPrefix = keyPrefix
147
1x
    apiKey.PermissionLevel = permissionLevel
148
1x

149
1x
    err = s.db.QueryRowContext(ctx, query, userID, keyName, keyHash, keyPrefix, permissionLevel, now, now).
150
1x
        Scan(&apiKey.ID, &apiKey.CreatedAt, &apiKey.UpdatedAt)
151
1x
    if err != nil {
152
        s.logger.Error(ctx, "Failed to create API key", err, map[string]interface{}{
153
            "user_id":          userID,
154
            "key_name":         keyName,
155
            "permission_level": permissionLevel,
156
        })
157
        span.RecordError(err)
158
        span.SetStatus(codes.Error, "failed to insert API key")
159
        return nil, "", contextutils.WrapError(err, "failed to create API key")
160
    }
161

162
1x
    span.SetAttributes(attribute.Int("api_key_id", apiKey.ID))
163
1x
    s.logger.Info(ctx, "Created new API key", map[string]interface{}{
164
1x
        "user_id":          userID,
165
1x
        "api_key_id":       apiKey.ID,
166
1x
        "key_name":         keyName,
167
1x
        "permission_level": permissionLevel,
168
1x
    })
169
1x

170
1x
    // Return the API key object and the raw key (only time it's returned)
171
1x
    return &apiKey, rawKey, nil
172
}
173

174
// ListAPIKeys returns all API keys for a user
175
8x
func (s *AuthAPIKeyService) ListAPIKeys(ctx context.Context, userID int) ([]models.AuthAPIKey, error) {
176
8x
    ctx, span := observability.TraceFunction(ctx, "auth_api_key_service", "list_api_keys")
177
8x
    defer observability.FinishSpan(span, nil)
178
8x

179
8x
    span.SetAttributes(attribute.Int("user_id", userID))
180
8x

181
8x
    query := `
182
8x
        SELECT id, user_id, key_name, key_hash, key_prefix, permission_level, last_used_at, created_at, updated_at
183
8x
        FROM auth_api_keys
184
8x
        WHERE user_id = $1
185
8x
        ORDER BY created_at DESC
186
8x
    `
187
8x

188
8x
    rows, err := s.db.QueryContext(ctx, query, userID)
189
8x
    if err != nil {
190
1x
        s.logger.Error(ctx, "Failed to list API keys", err, map[string]interface{}{"user_id": userID})
191
1x
        span.RecordError(err)
192
1x
        span.SetStatus(codes.Error, "failed to query API keys")
193
1x
        return nil, contextutils.WrapError(err, "failed to list API keys")
194
1x
    }
195
6x
    defer func() { _ = rows.Close() }()
196

197
6x
    var apiKeys []models.AuthAPIKey
198
6x
    for rows.Next() {
199
3x
        var apiKey models.AuthAPIKey
200
3x
        err := rows.Scan(
201
3x
            &apiKey.ID,
202
3x
            &apiKey.UserID,
203
3x
            &apiKey.KeyName,
204
3x
            &apiKey.KeyHash,
205
3x
            &apiKey.KeyPrefix,
206
3x
            &apiKey.PermissionLevel,
207
3x
            &apiKey.LastUsedAt,
208
3x
            &apiKey.CreatedAt,
209
3x
            &apiKey.UpdatedAt,
210
3x
        )
211
3x
        if err != nil {
212
1x
            s.logger.Error(ctx, "Failed to scan API key", err, map[string]interface{}{"user_id": userID})
213
1x
            span.RecordError(err)
214
1x
            span.SetStatus(codes.Error, "failed to scan API key")
215
1x
            return nil, contextutils.WrapError(err, "failed to scan API key")
216
1x
        }
217
1x
        apiKeys = append(apiKeys, apiKey)
218
    }
219

220
4x
    if err := rows.Err(); err != nil {
221
1x
        s.logger.Error(ctx, "Error iterating API keys", err, map[string]interface{}{"user_id": userID})
222
1x
        span.RecordError(err)
223
1x
        span.SetStatus(codes.Error, "failed to iterate API keys")
224
1x
        return nil, contextutils.WrapError(err, "failed to list API keys")
225
1x
    }
226

227
2x
    span.SetAttributes(attribute.Int("count", len(apiKeys)))
228
2x
    return apiKeys, nil
229
}
230

231
// GetAPIKeyByID retrieves a specific API key by ID for a user
232
3x
func (s *AuthAPIKeyService) GetAPIKeyByID(ctx context.Context, userID, keyID int) (*models.AuthAPIKey, error) {
233
3x
    ctx, span := observability.TraceFunction(ctx, "auth_api_key_service", "get_api_key_by_id")
234
3x
    defer observability.FinishSpan(span, nil)
235
3x

236
3x
    span.SetAttributes(
237
3x
        attribute.Int("user_id", userID),
238
3x
        attribute.Int("key_id", keyID),
239
3x
    )
240
3x

241
3x
    query := `
242
3x
        SELECT id, user_id, key_name, key_hash, key_prefix, permission_level, last_used_at, created_at, updated_at
243
3x
        FROM auth_api_keys
244
3x
        WHERE id = $1 AND user_id = $2
245
3x
    `
246
3x

247
3x
    var apiKey models.AuthAPIKey
248
3x
    err := s.db.QueryRowContext(ctx, query, keyID, userID).Scan(
249
3x
        &apiKey.ID,
250
3x
        &apiKey.UserID,
251
3x
        &apiKey.KeyName,
252
3x
        &apiKey.KeyHash,
253
3x
        &apiKey.KeyPrefix,
254
3x
        &apiKey.PermissionLevel,
255
3x
        &apiKey.LastUsedAt,
256
3x
        &apiKey.CreatedAt,
257
3x
        &apiKey.UpdatedAt,
258
3x
    )
259
3x

260
3x
    if err == sql.ErrNoRows {
261
1x
        return nil, nil
262
1x
    }
263

264
1x
    if err != nil {
265
        s.logger.Error(ctx, "Failed to get API key", err, map[string]interface{}{
266
            "user_id": userID,
267
            "key_id":  keyID,
268
        })
269
        span.RecordError(err)
270
        span.SetStatus(codes.Error, "failed to get API key")
271
        return nil, contextutils.WrapError(err, "failed to get API key")
272
    }
273

274
1x
    return &apiKey, nil
275
}
276

277
// DeleteAPIKey deletes an API key
278
2x
func (s *AuthAPIKeyService) DeleteAPIKey(ctx context.Context, userID, keyID int) error {
279
2x
    ctx, span := observability.TraceFunction(ctx, "auth_api_key_service", "delete_api_key")
280
2x
    defer observability.FinishSpan(span, nil)
281
2x

282
2x
    span.SetAttributes(
283
2x
        attribute.Int("user_id", userID),
284
2x
        attribute.Int("key_id", keyID),
285
2x
    )
286
2x

287
2x
    query := `DELETE FROM auth_api_keys WHERE id = $1 AND user_id = $2`
288
2x

289
2x
    result, err := s.db.ExecContext(ctx, query, keyID, userID)
290
2x
    if err != nil {
291
        s.logger.Error(ctx, "Failed to delete API key", err, map[string]interface{}{
292
            "user_id": userID,
293
            "key_id":  keyID,
294
        })
295
        span.RecordError(err)
296
        span.SetStatus(codes.Error, "failed to delete API key")
297
        return contextutils.WrapError(err, "failed to delete API key")
298
    }
299

300
2x
    rowsAffected, err := result.RowsAffected()
301
2x
    if err != nil {
302
        s.logger.Error(ctx, "Failed to get rows affected", err, map[string]interface{}{
303
            "user_id": userID,
304
            "key_id":  keyID,
305
        })
306
        span.RecordError(err)
307
        span.SetStatus(codes.Error, "failed to get rows affected")
308
        return contextutils.WrapError(err, "failed to check deletion")
309
    }
310

311
2x
    if rowsAffected == 0 {
312
1x
        err := contextutils.NewAppError(
313
1x
            contextutils.ErrorCodeRecordNotFound,
314
1x
            contextutils.SeverityWarn,
315
1x
            "API key not found",
316
1x
            "",
317
1x
        )
318
1x
        span.RecordError(err)
319
1x
        span.SetStatus(codes.Error, "API key not found")
320
1x
        return err
321
1x
    }
322

323
1x
    s.logger.Info(ctx, "Deleted API key", map[string]interface{}{
324
1x
        "user_id": userID,
325
1x
        "key_id":  keyID,
326
1x
    })
327
1x

328
1x
    return nil
329
}
330

331
// ValidateAPIKey validates a raw API key and returns the associated key info
332
5x
func (s *AuthAPIKeyService) ValidateAPIKey(ctx context.Context, rawKey string) (*models.AuthAPIKey, error) {
333
5x
    ctx, span := observability.TraceFunction(ctx, "auth_api_key_service", "validate_api_key")
334
5x
    defer observability.FinishSpan(span, nil)
335
5x

336
5x
    // Basic validation
337
5x
    if rawKey == "" {
338
1x
        return nil, errors.New("API key is empty")
339
1x
    }
340

341
4x
    if len(rawKey) < len(KeyPrefix) || rawKey[:len(KeyPrefix)] != KeyPrefix {
342
1x
        span.SetStatus(codes.Error, "invalid API key format")
343
1x
        return nil, errors.New("invalid API key format")
344
1x
    }
345

346
    // Query all API keys with matching prefix for this key
347
    // We need to check all because we hash the keys
348
3x
    query := `
349
3x
        SELECT id, user_id, key_name, key_hash, key_prefix, permission_level, last_used_at, created_at, updated_at
350
3x
        FROM auth_api_keys
351
3x
    `
352
3x

353
3x
    rows, err := s.db.QueryContext(ctx, query)
354
3x
    if err != nil {
355
        s.logger.Error(ctx, "Failed to query API keys for validation", err, nil)
356
        span.RecordError(err)
357
        span.SetStatus(codes.Error, "failed to query API keys")
358
        return nil, contextutils.WrapError(err, "failed to validate API key")
359
    }
360
3x
    defer func() { _ = rows.Close() }()
361

362
    // Check each key by comparing bcrypt hash
363
3x
    for rows.Next() {
364
1x
        var apiKey models.AuthAPIKey
365
1x
        err := rows.Scan(
366
1x
            &apiKey.ID,
367
1x
            &apiKey.UserID,
368
1x
            &apiKey.KeyName,
369
1x
            &apiKey.KeyHash,
370
1x
            &apiKey.KeyPrefix,
371
1x
            &apiKey.PermissionLevel,
372
1x
            &apiKey.LastUsedAt,
373
1x
            &apiKey.CreatedAt,
374
1x
            &apiKey.UpdatedAt,
375
1x
        )
376
1x
        if err != nil {
377
            s.logger.Error(ctx, "Failed to scan API key", err, nil)
378
            continue
379
        }
380

381
        // Compare hash
382
1x
        err = bcrypt.CompareHashAndPassword([]byte(apiKey.KeyHash), []byte(rawKey))
383
1x
        if err == nil {
384
1x
            // Found matching key
385
1x
            span.SetAttributes(
386
1x
                attribute.Int("api_key_id", apiKey.ID),
387
1x
                attribute.Int("user_id", apiKey.UserID),
388
1x
                attribute.String("permission_level", apiKey.PermissionLevel),
389
1x
            )
390
1x
            return &apiKey, nil
391
1x
        }
392
    }
393

394
1x
    if err := rows.Err(); err != nil {
395
1x
        s.logger.Error(ctx, "Error iterating API keys", err, nil)
396
1x
        span.RecordError(err)
397
1x
        span.SetStatus(codes.Error, "failed to iterate API keys")
398
1x
        return nil, contextutils.WrapError(err, "failed to validate API key")
399
1x
    }
400

401
    // No matching key found
402
    span.SetStatus(codes.Error, "invalid API key")
403
    return nil, errors.New("invalid API key")
404
}
405

406
// UpdateLastUsed updates the last_used_at timestamp for an API key
407
// This should be called asynchronously to avoid blocking requests
408
3x
func (s *AuthAPIKeyService) UpdateLastUsed(ctx context.Context, keyID int) error {
409
3x
    ctx, span := observability.TraceFunction(ctx, "auth_api_key_service", "update_last_used")
410
3x
    defer observability.FinishSpan(span, nil)
411
3x

412
3x
    span.SetAttributes(attribute.Int("key_id", keyID))
413
3x

414
3x
    query := `UPDATE auth_api_keys SET last_used_at = $1, updated_at = $2 WHERE id = $3`
415
3x

416
3x
    now := time.Now()
417
3x
    _, err := s.db.ExecContext(ctx, query, now, now, keyID)
418
3x
    if err != nil {
419
1x
        s.logger.Error(ctx, "Failed to update last used timestamp", err, map[string]interface{}{
420
1x
            "key_id": keyID,
421
1x
        })
422
1x
        span.RecordError(err)
423
1x
        span.SetStatus(codes.Error, "failed to update last used")
424
1x
        // Don't return error - this is not critical
425
1x
        return nil
426
1x
    }
427

428
1x
    return nil
429
}
430


			
quizapp internal services worker_service.go
83.5%
Statements
86/103
1
package services
2

3
import (
4
    "context"
5
    "database/sql"
6
    "errors"
7
    "time"
8

9
    "go.opentelemetry.io/otel/attribute"
10
    "go.opentelemetry.io/otel/codes"
11
    "go.opentelemetry.io/otel/trace"
12

13
    "quizapp/internal/observability"
14
)
15

16
// CleanupService handles database maintenance and cleanup tasks
17
type CleanupService struct {
18
    db     *sql.DB
19
    logger *observability.Logger
20
}
21

22
// NewCleanupServiceWithLogger creates a new cleanup service with logger
23
27x
func NewCleanupServiceWithLogger(db *sql.DB, logger *observability.Logger) *CleanupService {
24
27x
    return &CleanupService{
25
27x
        db:     db,
26
27x
        logger: logger,
27
27x
    }
28
27x
}
29

30
// CleanupLegacyQuestionTypes removes questions with unsupported question types
31
12x
func (c *CleanupService) CleanupLegacyQuestionTypes(ctx context.Context) (err error) {
32
12x
    ctx, span := observability.TraceCleanupFunction(ctx, "cleanup_legacy_question_types")
33
12x
    defer func() {
34
12x
        if err != nil {
35
4x
            span.RecordError(err, trace.WithStackTrace(true))
36
4x
            span.SetStatus(codes.Error, err.Error())
37
4x
        }
38
12x
        span.End()
39
    }()
40

41
    // Check if database is available
42
12x
    if c.db == nil {
43
3x
        return errors.New("database connection not available")
44
3x
    }
45

46
    // Get count of legacy questions first
47
9x
    var count int
48
9x
    err = c.db.QueryRowContext(ctx, `
49
9x
        SELECT COUNT(*)
50
9x
        FROM questions
51
9x
        WHERE type NOT IN ('vocabulary', 'fill_blank', 'qa', 'reading_comprehension')
52
9x
    `).Scan(&count)
53
9x
    if err != nil {
54
1x
        span.SetAttributes(attribute.String("error", err.Error()))
55
1x
        return err
56
1x
    }
57

58
8x
    span.SetAttributes(attribute.Int("cleanup.legacy_questions_count", count))
59
8x

60
8x
    if count == 0 {
61
5x
        c.logger.Info(ctx, "No legacy question types found to cleanup", map[string]interface{}{})
62
5x
        span.SetAttributes(attribute.String("cleanup.result", "no_legacy_questions"))
63
5x
        return nil
64
5x
    }
65

66
3x
    c.logger.Info(ctx, "Found questions with legacy types to cleanup", map[string]interface{}{"count": count})
67
3x

68
3x
    // Delete questions with unsupported types
69
3x
    result, err := c.db.ExecContext(ctx, `
70
3x
        DELETE FROM questions
71
3x
        WHERE type NOT IN ('vocabulary', 'fill_blank', 'qa', 'reading_comprehension')
72
3x
    `)
73
3x
    if err != nil {
74
        span.SetAttributes(attribute.String("error", err.Error()))
75
        return err
76
    }
77

78
3x
    rowsAffected, err := result.RowsAffected()
79
3x
    if err != nil {
80
        span.SetAttributes(attribute.String("error", err.Error()))
81
        return err
82
    }
83

84
3x
    span.SetAttributes(
85
3x
        attribute.Int64("cleanup.rows_affected", rowsAffected),
86
3x
        attribute.String("cleanup.result", "success"),
87
3x
    )
88
3x

89
3x
    c.logger.Info(ctx, "Successfully cleaned up questions with legacy types", map[string]interface{}{"rows_affected": rowsAffected})
90
3x
    return nil
91
}
92

93
// CleanupOrphanedResponses removes user responses for questions that no longer exist
94
4x
func (c *CleanupService) CleanupOrphanedResponses(ctx context.Context) (err error) {
95
4x
    ctx, span := observability.TraceCleanupFunction(ctx, "cleanup_orphaned_responses")
96
4x
    defer func() {
97
4x
        if err != nil {
98
1x
            span.RecordError(err, trace.WithStackTrace(true))
99
1x
            span.SetStatus(codes.Error, err.Error())
100
1x
        }
101
4x
        span.End()
102
    }()
103

104
    // Check if database is available
105
4x
    if c.db == nil {
106
1x
        return errors.New("database connection not available")
107
1x
    }
108

109
3x
    var count int
110
3x
    err = c.db.QueryRowContext(ctx, `
111
3x
        SELECT COUNT(*)
112
3x
        FROM user_responses ur
113
3x
        LEFT JOIN questions q ON ur.question_id = q.id
114
3x
        WHERE q.id IS NULL
115
3x
    `).Scan(&count)
116
3x
    if err != nil {
117
        span.SetAttributes(attribute.String("error", err.Error()))
118
        return err
119
    }
120

121
3x
    span.SetAttributes(attribute.Int("cleanup.orphaned_responses_count", count))
122
3x

123
3x
    if count == 0 {
124
2x
        c.logger.Info(ctx, "No orphaned responses found to cleanup", map[string]interface{}{})
125
2x
        span.SetAttributes(attribute.String("cleanup.result", "no_orphaned_responses"))
126
2x
        return nil
127
2x
    }
128

129
1x
    c.logger.Info(ctx, "Found orphaned responses to cleanup", map[string]interface{}{"count": count})
130
1x

131
1x
    result, err := c.db.ExecContext(ctx, `
132
1x
        DELETE FROM user_responses
133
1x
        WHERE question_id NOT IN (SELECT id FROM questions)
134
1x
    `)
135
1x
    if err != nil {
136
        span.SetAttributes(attribute.String("error", err.Error()))
137
        return err
138
    }
139

140
1x
    rowsAffected, err := result.RowsAffected()
141
1x
    if err != nil {
142
        span.SetAttributes(attribute.String("error", err.Error()))
143
        return err
144
    }
145

146
1x
    span.SetAttributes(
147
1x
        attribute.Int64("cleanup.rows_affected", rowsAffected),
148
1x
        attribute.String("cleanup.result", "success"),
149
1x
    )
150
1x

151
1x
    c.logger.Info(ctx, "Successfully cleaned up orphaned responses", map[string]interface{}{"rows_affected": rowsAffected})
152
1x
    return nil
153
}
154

155
// RunFullCleanup performs all cleanup operations
156
2x
func (c *CleanupService) RunFullCleanup(ctx context.Context) (err error) {
157
2x
    ctx, span := observability.TraceCleanupFunction(ctx, "run_full_cleanup")
158
2x
    defer func() {
159
2x
        if err != nil {
160
1x
            span.RecordError(err, trace.WithStackTrace(true))
161
1x
            span.SetStatus(codes.Error, err.Error())
162
1x
        }
163
2x
        span.End()
164
    }()
165

166
2x
    span.SetAttributes(attribute.String("cleanup.start_time", time.Now().Format(time.RFC3339)))
167
2x

168
2x
    c.logger.Info(ctx, "Starting database cleanup", map[string]interface{}{"start_time": time.Now().Format(time.RFC3339)})
169
2x

170
2x
    if err = c.CleanupLegacyQuestionTypes(ctx); err != nil {
171
1x
        c.logger.Error(ctx, "Failed to cleanup legacy question types", err, map[string]interface{}{})
172
1x
        span.SetAttributes(attribute.String("error", err.Error()))
173
1x
        return err
174
1x
    }
175

176
1x
    if err := c.CleanupOrphanedResponses(ctx); err != nil {
177
        c.logger.Error(ctx, "Failed to cleanup orphaned responses", err, map[string]interface{}{})
178
        span.SetAttributes(attribute.String("error", err.Error()))
179
        return err
180
    }
181

182
1x
    span.SetAttributes(
183
1x
        attribute.String("cleanup.end_time", time.Now().Format(time.RFC3339)),
184
1x
        attribute.String("cleanup.result", "success"),
185
1x
    )
186
1x

187
1x
    c.logger.Info(ctx, "Database cleanup completed successfully", map[string]interface{}{"end_time": time.Now().Format(time.RFC3339)})
188
1x
    return nil
189
}
190

191
// GetCleanupStats returns statistics about cleanup operations
192
2x
func (c *CleanupService) GetCleanupStats(ctx context.Context) (result0 map[string]int, err error) {
193
2x
    ctx, span := observability.TraceCleanupFunction(ctx, "get_cleanup_stats")
194
2x
    defer func() {
195
2x
        if err != nil {
196
1x
            span.RecordError(err, trace.WithStackTrace(true))
197
1x
            span.SetStatus(codes.Error, err.Error())
198
1x
        }
199
2x
        span.End()
200
    }()
201

202
    // Check if database is available
203
2x
    if c.db == nil {
204
1x
        return nil, errors.New("database connection not available")
205
1x
    }
206

207
1x
    stats := make(map[string]int)
208
1x

209
1x
    // Count legacy question types
210
1x
    var legacyCount int
211
1x
    err = c.db.QueryRowContext(ctx, `
212
1x
        SELECT COUNT(*)
213
1x
        FROM questions
214
1x
        WHERE type NOT IN ('vocabulary', 'fill_blank', 'qa', 'reading_comprehension')
215
1x
    `).Scan(&legacyCount)
216
1x
    if err != nil {
217
        span.SetAttributes(attribute.String("error", err.Error()))
218
        return nil, err
219
    }
220
1x
    stats["legacy_questions"] = legacyCount
221
1x

222
1x
    // Count orphaned responses
223
1x
    var orphanedCount int
224
1x
    err = c.db.QueryRowContext(ctx, `
225
1x
        SELECT COUNT(*)
226
1x
        FROM user_responses ur
227
1x
        LEFT JOIN questions q ON ur.question_id = q.id
228
1x
        WHERE q.id IS NULL
229
1x
    `).Scan(&orphanedCount)
230
1x
    if err != nil {
231
        span.SetAttributes(attribute.String("error", err.Error()))
232
        return nil, err
233
    }
234
1x
    stats["orphaned_responses"] = orphanedCount
235
1x

236
1x
    span.SetAttributes(
237
1x
        attribute.Int("cleanup.stats.legacy_questions", legacyCount),
238
1x
        attribute.Int("cleanup.stats.orphaned_responses", orphanedCount),
239
1x
    )
240
1x

241
1x
    return stats, nil
242
}
243


			
quizapp internal services worker_service.go
45.8%
Statements
125/273
1
package services
2

3
import (
4
    "context"
5
    "database/sql"
6
    "encoding/json"
7
    "fmt"
8
    "strings"
9
    "time"
10

11
    "quizapp/internal/api"
12
    contextutils "quizapp/internal/utils"
13

14
    "github.com/google/uuid"
15
)
16

17
// ConversationServiceInterface defines the interface for AI conversation operations
18
type ConversationServiceInterface interface {
19
    // Conversation CRUD operations
20
    CreateConversation(ctx context.Context, userID uint, req *api.CreateConversationRequest) (*api.Conversation, error)
21
    GetConversation(ctx context.Context, conversationID string, userID uint) (*api.Conversation, error)
22
    GetUserConversations(ctx context.Context, userID uint, limit, offset int) ([]api.Conversation, int, error)
23
    UpdateConversation(ctx context.Context, conversationID string, userID uint, req *api.UpdateConversationRequest) (*api.Conversation, error)
24
    DeleteConversation(ctx context.Context, conversationID string, userID uint) error
25

26
    // Message operations
27
    AddMessage(ctx context.Context, conversationID string, userID uint, req *api.CreateMessageRequest) (*api.ChatMessage, error)
28
    GetConversationMessages(ctx context.Context, conversationID string, userID uint) ([]api.ChatMessage, error)
29
    ToggleMessageBookmark(ctx context.Context, conversationID, messageID string, userID uint) (bool, error)
30

31
    // Search operations
32
    SearchMessages(ctx context.Context, userID uint, query string, limit, offset int) ([]api.ChatMessage, int, error)
33
    SearchConversations(ctx context.Context, userID uint, query string, limit, offset int) ([]api.Conversation, int, error)
34

35
    // Bookmark operations
36
    GetBookmarkedMessages(ctx context.Context, userID uint, query string, limit, offset int) ([]api.ChatMessage, int, error)
37

38
    // Utility operations
39
    // GetUserMessageCounts returns a map of conversation ID -> message count for the user's conversations
40
    GetUserMessageCounts(ctx context.Context, userID uint) (map[string]int, error)
41
}
42

43
// ConversationService handles all AI conversation-related operations
44
type ConversationService struct {
45
    db *sql.DB
46
}
47

48
// NewConversationService creates a new ConversationService
49
1x
func NewConversationService(db *sql.DB) *ConversationService {
50
1x
    return &ConversationService{
51
1x
        db: db,
52
1x
    }
53
1x
}
54

55
// CreateConversation creates a new AI conversation
56
10x
func (s *ConversationService) CreateConversation(ctx context.Context, userID uint, req *api.CreateConversationRequest) (*api.Conversation, error) {
57
10x
    conversationID := uuid.New()
58
10x

59
10x
    query := `
60
10x
        INSERT INTO ai_conversations (id, user_id, title, created_at, updated_at)
61
10x
        VALUES ($1, $2, $3, $4, $5)
62
10x
        RETURNING id, user_id, title, created_at, updated_at`
63
10x

64
10x
    var conversation api.Conversation
65
10x
    err := s.db.QueryRowContext(ctx, query,
66
10x
        conversationID,
67
10x
        userID,
68
10x
        req.Title,
69
10x
        time.Now(),
70
10x
        time.Now(),
71
10x
    ).Scan(
72
10x
        &conversation.Id,
73
10x
        &conversation.UserId,
74
10x
        &conversation.Title,
75
10x
        &conversation.CreatedAt,
76
10x
        &conversation.UpdatedAt,
77
10x
    )
78
10x
    if err != nil {
79
        return nil, contextutils.WrapError(err, "failed to create conversation")
80
    }
81

82
10x
    return &conversation, nil
83
}
84

85
// GetConversation retrieves a conversation with all its messages
86
3x
func (s *ConversationService) GetConversation(ctx context.Context, conversationID string, userID uint) (*api.Conversation, error) {
87
3x
    // First get the conversation
88
3x
    query := `
89
3x
        SELECT id, user_id, title, created_at, updated_at
90
3x
        FROM ai_conversations
91
3x
        WHERE id = $1 AND user_id = $2`
92
3x

93
3x
    var conversation api.Conversation
94
3x
    err := s.db.QueryRowContext(ctx, query, conversationID, userID).Scan(
95
3x
        &conversation.Id,
96
3x
        &conversation.UserId,
97
3x
        &conversation.Title,
98
3x
        &conversation.CreatedAt,
99
3x
        &conversation.UpdatedAt,
100
3x
    )
101
3x
    if err != nil {
102
1x
        if err == sql.ErrNoRows {
103
1x
            return nil, contextutils.ErrorWithContextf("conversation not found")
104
1x
        }
105
        return nil, contextutils.WrapError(err, "failed to get conversation")
106
    }
107

108
    // Get the messages for this conversation
109
2x
    messages, err := s.GetConversationMessages(ctx, conversationID, userID)
110
2x
    if err != nil {
111
        return nil, contextutils.WrapError(err, "failed to get conversation messages")
112
    }
113

114
    // Ensure messages is never nil - always point to a valid slice
115
2x
    if messages == nil {
116
1x
        messages = []api.ChatMessage{}
117
1x
    }
118
2x
    conversation.Messages = &messages
119
2x

120
2x
    return &conversation, nil
121
}
122

123
// GetUserConversations retrieves all conversations for a user with pagination
124
2x
func (s *ConversationService) GetUserConversations(ctx context.Context, userID uint, limit, offset int) ([]api.Conversation, int, error) {
125
2x
    // Get total count
126
2x
    countQuery := `SELECT COUNT(*) FROM ai_conversations WHERE user_id = $1`
127
2x
    var total int
128
2x
    err := s.db.QueryRowContext(ctx, countQuery, userID).Scan(&total)
129
2x
    if err != nil {
130
        return nil, 0, contextutils.WrapError(err, "failed to count conversations")
131
    }
132

133
    // Get conversations with pagination
134
2x
    query := `
135
2x
        SELECT id, user_id, title, created_at, updated_at
136
2x
        FROM ai_conversations
137
2x
        WHERE user_id = $1
138
2x
        ORDER BY updated_at DESC
139
2x
        LIMIT $2 OFFSET $3`
140
2x

141
2x
    rows, err := s.db.QueryContext(ctx, query, userID, limit, offset)
142
2x
    if err != nil {
143
        return nil, 0, contextutils.WrapError(err, "failed to query conversations")
144
    }
145
2x
    defer func() { _ = rows.Close() }()
146

147
2x
    var conversations []api.Conversation
148
2x
    for rows.Next() {
149
3x
        var conv api.Conversation
150
3x
        err := rows.Scan(
151
3x
            &conv.Id,
152
3x
            &conv.UserId,
153
3x
            &conv.Title,
154
3x
            &conv.CreatedAt,
155
3x
            &conv.UpdatedAt,
156
3x
        )
157
3x
        if err != nil {
158
            return nil, 0, contextutils.WrapError(err, "failed to scan conversation")
159
        }
160
3x
        conversations = append(conversations, conv)
161
    }
162

163
2x
    if err := rows.Err(); err != nil {
164
        return nil, 0, contextutils.WrapError(err, "error iterating conversations")
165
    }
166

167
2x
    return conversations, total, nil
168
}
169

170
// GetUserMessageCounts returns message counts for all conversations for a user
171
func (s *ConversationService) GetUserMessageCounts(ctx context.Context, userID uint) (map[string]int, error) {
172
    query := `
173
        SELECT c.id::text AS id, COUNT(m.id) AS message_count
174
        FROM ai_conversations c
175
        LEFT JOIN ai_chat_messages m ON m.conversation_id = c.id
176
        WHERE c.user_id = $1
177
        GROUP BY c.id`
178

179
    rows, err := s.db.QueryContext(ctx, query, userID)
180
    if err != nil {
181
        return nil, contextutils.WrapError(err, "failed to query message counts")
182
    }
183
    defer func() { _ = rows.Close() }()
184

185
    counts := make(map[string]int)
186
    for rows.Next() {
187
        var id string
188
        var count int
189
        if err := rows.Scan(&id, &count); err != nil {
190
            return nil, contextutils.WrapError(err, "failed to scan message count")
191
        }
192
        counts[id] = count
193
    }
194
    if err := rows.Err(); err != nil {
195
        return nil, contextutils.WrapError(err, "error iterating message counts")
196
    }
197
    return counts, nil
198
}
199

200
// UpdateConversation updates a conversation's title
201
1x
func (s *ConversationService) UpdateConversation(ctx context.Context, conversationID string, userID uint, req *api.UpdateConversationRequest) (*api.Conversation, error) {
202
1x
    query := `
203
1x
        UPDATE ai_conversations
204
1x
        SET title = $1, updated_at = $2
205
1x
        WHERE id = $3 AND user_id = $4
206
1x
        RETURNING id, user_id, title, created_at, updated_at`
207
1x

208
1x
    var conversation api.Conversation
209
1x
    err := s.db.QueryRowContext(ctx, query,
210
1x
        req.Title,
211
1x
        time.Now(),
212
1x
        conversationID,
213
1x
        userID,
214
1x
    ).Scan(
215
1x
        &conversation.Id,
216
1x
        &conversation.UserId,
217
1x
        &conversation.Title,
218
1x
        &conversation.CreatedAt,
219
1x
        &conversation.UpdatedAt,
220
1x
    )
221
1x
    if err != nil {
222
        if err == sql.ErrNoRows {
223
            return nil, contextutils.ErrorWithContextf("conversation not found")
224
        }
225
        return nil, contextutils.WrapError(err, "failed to update conversation")
226
    }
227

228
1x
    return &conversation, nil
229
}
230

231
// DeleteConversation deletes a conversation and all its messages
232
1x
func (s *ConversationService) DeleteConversation(ctx context.Context, conversationID string, userID uint) error {
233
1x
    // First verify the conversation belongs to the user
234
1x
    var ownerID uint
235
1x
    err := s.db.QueryRowContext(ctx, "SELECT user_id FROM ai_conversations WHERE id = $1", conversationID).Scan(&ownerID)
236
1x
    if err != nil {
237
        if err == sql.ErrNoRows {
238
            return contextutils.ErrorWithContextf("conversation not found")
239
        }
240
        return contextutils.WrapError(err, "failed to verify conversation ownership")
241
    }
242

243
1x
    if ownerID != userID {
244
        return contextutils.ErrorWithContextf("conversation not found")
245
    }
246

247
    // Delete the conversation (CASCADE will delete associated messages)
248
1x
    query := `DELETE FROM ai_conversations WHERE id = $1 AND user_id = $2`
249
1x
    result, err := s.db.ExecContext(ctx, query, conversationID, userID)
250
1x
    if err != nil {
251
        return contextutils.WrapError(err, "failed to delete conversation")
252
    }
253

254
1x
    rowsAffected, err := result.RowsAffected()
255
1x
    if err != nil {
256
        return contextutils.WrapError(err, "failed to get rows affected")
257
    }
258

259
1x
    if rowsAffected == 0 {
260
        return contextutils.ErrorWithContextf("conversation not found")
261
    }
262

263
1x
    return nil
264
}
265

266
// AddMessage adds a new message to a conversation
267
7x
func (s *ConversationService) AddMessage(ctx context.Context, conversationID string, userID uint, req *api.CreateMessageRequest) (*api.ChatMessage, error) {
268
7x
    // First verify the conversation belongs to the user
269
7x
    var ownerID uint
270
7x
    err := s.db.QueryRowContext(ctx, "SELECT user_id FROM ai_conversations WHERE id = $1", conversationID).Scan(&ownerID)
271
7x
    if err != nil {
272
        if err == sql.ErrNoRows {
273
            return nil, contextutils.ErrorWithContextf("conversation not found")
274
        }
275
        return nil, contextutils.WrapError(err, "failed to verify conversation ownership")
276
    }
277

278
7x
    if ownerID != userID {
279
        return nil, contextutils.ErrorWithContextf("conversation not found")
280
    }
281

282
7x
    messageID := uuid.New()
283
7x
    query := `
284
7x
        INSERT INTO ai_chat_messages (id, conversation_id, question_id, role, answer_json, bookmarked, created_at, updated_at)
285
7x
        VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
286
7x
        RETURNING id, conversation_id, question_id, role, answer_json, bookmarked, created_at, updated_at`
287
7x

288
7x
    var message api.ChatMessage
289
7x
    var questionIDPtr *int
290
7x
    if req.QuestionId != nil {
291
        questionIDPtr = req.QuestionId
292
    }
293

294
    // Store content directly as JSON string
295
7x
    contentJSON, err := json.Marshal(req.Content)
296
7x
    if err != nil {
297
        return nil, contextutils.WrapError(err, "failed to marshal message content")
298
    }
299

300
7x
    var contentBytes []byte
301
7x
    err = s.db.QueryRowContext(ctx, query,
302
7x
        messageID,
303
7x
        conversationID,
304
7x
        questionIDPtr,
305
7x
        string(req.Role),
306
7x
        contentJSON, // Store as JSON string value
307
7x
        false,       // bookmarked defaults to false
308
7x
        time.Now(),
309
7x
        time.Now(),
310
7x
    ).Scan(
311
7x
        &message.Id,
312
7x
        &message.ConversationId,
313
7x
        &message.QuestionId,
314
7x
        &message.Role,
315
7x
        &contentBytes,
316
7x
        &message.Bookmarked,
317
7x
        &message.CreatedAt,
318
7x
        &message.UpdatedAt,
319
7x
    )
320
7x
    if err != nil {
321
        return nil, contextutils.WrapError(err, "failed to add message")
322
    }
323

324
    // Unmarshal the content from bytes
325
7x
    var contentObj struct {
326
7x
        Text *string `json:"text,omitempty"`
327
7x
    }
328
7x
    err = json.Unmarshal(contentBytes, &contentObj)
329
7x
    if err != nil {
330
        return nil, contextutils.WrapError(err, "failed to unmarshal message content")
331
    }
332
7x
    message.Content = contentObj
333
7x

334
7x
    return &message, nil
335
}
336

337
// GetConversationMessages retrieves all messages for a conversation
338
4x
func (s *ConversationService) GetConversationMessages(ctx context.Context, conversationID string, userID uint) ([]api.ChatMessage, error) {
339
4x
    // First verify the conversation belongs to the user
340
4x
    var ownerID uint
341
4x
    err := s.db.QueryRowContext(ctx, "SELECT user_id FROM ai_conversations WHERE id = $1", conversationID).Scan(&ownerID)
342
4x
    if err != nil {
343
        if err == sql.ErrNoRows {
344
            return nil, contextutils.ErrorWithContextf("conversation not found")
345
        }
346
        return nil, contextutils.WrapError(err, "failed to verify conversation ownership")
347
    }
348

349
4x
    if ownerID != userID {
350
        return nil, contextutils.ErrorWithContextf("conversation not found")
351
    }
352

353
4x
    query := `
354
4x
        SELECT id, conversation_id, question_id, role, answer_json, bookmarked, created_at, updated_at
355
4x
        FROM ai_chat_messages
356
4x
        WHERE conversation_id = $1
357
4x
        ORDER BY created_at ASC`
358
4x

359
4x
    rows, err := s.db.QueryContext(ctx, query, conversationID)
360
4x
    if err != nil {
361
        return nil, contextutils.WrapError(err, "failed to query messages")
362
    }
363
4x
    defer func() { _ = rows.Close() }()
364

365
4x
    var messages []api.ChatMessage
366
4x
    for rows.Next() {
367
5x
        var msg api.ChatMessage
368
5x
        var questionIDPtr *int
369
5x

370
5x
        var answerBytes []byte
371
5x
        err := rows.Scan(
372
5x
            &msg.Id,
373
5x
            &msg.ConversationId,
374
5x
            &questionIDPtr,
375
5x
            &msg.Role,
376
5x
            &answerBytes,
377
5x
            &msg.Bookmarked,
378
5x
            &msg.CreatedAt,
379
5x
            &msg.UpdatedAt,
380
5x
        )
381
5x
        if err != nil {
382
            return nil, contextutils.WrapError(err, "failed to scan message")
383
        }
384

385
        // Content is now stored as an object, unmarshal accordingly
386
5x
        var contentObj struct {
387
5x
            Text *string `json:"text,omitempty"`
388
5x
        }
389
5x
        err = json.Unmarshal(answerBytes, &contentObj)
390
5x
        if err != nil {
391
            return nil, contextutils.WrapError(err, "failed to unmarshal message content")
392
        }
393
5x
        msg.Content = contentObj
394
5x
        if err != nil {
395
            return nil, contextutils.WrapError(err, "failed to unmarshal message content")
396
        }
397

398
5x
        if questionIDPtr != nil {
399
            msg.QuestionId = questionIDPtr
400
        }
401

402
5x
        messages = append(messages, msg)
403
    }
404

405
4x
    if err := rows.Err(); err != nil {
406
        return nil, contextutils.WrapError(err, "error iterating messages")
407
    }
408

409
4x
    return messages, nil
410
}
411

412
// ToggleMessageBookmark toggles the bookmark status of a message
413
func (s *ConversationService) ToggleMessageBookmark(ctx context.Context, conversationID, messageID string, userID uint) (bool, error) {
414
    // First verify the conversation belongs to the user
415
    var ownerID uint
416
    err := s.db.QueryRowContext(ctx, "SELECT user_id FROM ai_conversations WHERE id = $1", conversationID).Scan(&ownerID)
417
    if err != nil {
418
        if err == sql.ErrNoRows {
419
            return false, contextutils.ErrorWithContextf("conversation not found")
420
        }
421
        return false, contextutils.WrapError(err, "failed to verify conversation ownership")
422
    }
423

424
    if ownerID != userID {
425
        return false, contextutils.ErrorWithContextf("conversation not found")
426
    }
427

428
    // Get current bookmark status and toggle it
429
    var currentBookmarked bool
430
    err = s.db.QueryRowContext(ctx,
431
        "SELECT bookmarked FROM ai_chat_messages WHERE id = $1 AND conversation_id = $2",
432
        messageID, conversationID).Scan(&currentBookmarked)
433
    if err != nil {
434
        if err == sql.ErrNoRows {
435
            return false, contextutils.ErrorWithContextf("message not found")
436
        }
437
        return false, contextutils.WrapError(err, "failed to get message bookmark status")
438
    }
439

440
    newBookmarked := !currentBookmarked
441

442
    // Update the bookmark status
443
    query := `UPDATE ai_chat_messages SET bookmarked = $1, updated_at = $2 WHERE id = $3 AND conversation_id = $4`
444
    _, err = s.db.ExecContext(ctx, query, newBookmarked, time.Now(), messageID, conversationID)
445
    if err != nil {
446
        return false, contextutils.WrapError(err, "failed to update message bookmark status")
447
    }
448

449
    return newBookmarked, nil
450
}
451

452
// SearchMessages searches across all messages for a user
453
2x
func (s *ConversationService) SearchMessages(ctx context.Context, userID uint, query string, limit, offset int) ([]api.ChatMessage, int, error) {
454
2x
    // Clean and prepare the search query
455
2x
    searchQuery := strings.TrimSpace(query)
456
2x
    if searchQuery == "" {
457
        return nil, 0, contextutils.ErrorWithContextf("search query cannot be empty")
458
    }
459

460
    // Search in the answer_json column (which contains the message content as JSON string)
461
    // We need to search within the JSON string value, so we search for the pattern within quotes
462
2x
    searchTerm := fmt.Sprintf("%%%s%%", strings.ToLower(searchQuery))
463
2x

464
2x
    // Get total count of matching messages
465
2x
    countQuery := `
466
2x
        SELECT COUNT(*)
467
2x
        FROM ai_chat_messages m
468
2x
        JOIN ai_conversations c ON m.conversation_id = c.id
469
2x
        WHERE c.user_id = $1 AND LOWER(m.answer_json::text) LIKE $2`
470
2x

471
2x
    var total int
472
2x
    err := s.db.QueryRowContext(ctx, countQuery, userID, searchTerm).Scan(&total)
473
2x
    if err != nil {
474
        return nil, 0, contextutils.WrapError(err, "failed to count search results")
475
    }
476

477
    // Get messages with conversation titles
478
2x
    querySQL := `
479
2x
        SELECT m.id, m.conversation_id, m.question_id, m.role, m.answer_json::text, m.bookmarked, m.created_at, m.updated_at, c.title
480
2x
        FROM ai_chat_messages m
481
2x
        JOIN ai_conversations c ON m.conversation_id = c.id
482
2x
        WHERE c.user_id = $1 AND LOWER(m.answer_json::text) LIKE $2
483
2x
        ORDER BY m.created_at DESC
484
2x
        LIMIT $3 OFFSET $4`
485
2x

486
2x
    rows, err := s.db.QueryContext(ctx, querySQL, userID, searchTerm, limit, offset)
487
2x
    if err != nil {
488
        return nil, 0, contextutils.WrapError(err, "failed to search messages")
489
    }
490
2x
    defer func() { _ = rows.Close() }()
491

492
2x
    var messages []api.ChatMessage
493
2x
    for rows.Next() {
494
3x
        var msg api.ChatMessage
495
3x
        var questionIDPtr *int
496
3x
        var conversationTitle string
497
3x

498
3x
        var answerBytes []byte
499
3x
        err := rows.Scan(
500
3x
            &msg.Id,
501
3x
            &msg.ConversationId,
502
3x
            &questionIDPtr,
503
3x
            &msg.Role,
504
3x
            &answerBytes,
505
3x
            &msg.Bookmarked,
506
3x
            &msg.CreatedAt,
507
3x
            &msg.UpdatedAt,
508
3x
            &conversationTitle,
509
3x
        )
510
3x
        if err != nil {
511
            return nil, 0, contextutils.WrapError(err, "failed to scan search result")
512
        }
513

514
        // Content is now stored as an object, unmarshal accordingly
515
3x
        var contentObj struct {
516
3x
            Text *string `json:"text,omitempty"`
517
3x
        }
518
3x
        err = json.Unmarshal(answerBytes, &contentObj)
519
3x
        if err != nil {
520
            return nil, 0, contextutils.WrapError(err, "failed to unmarshal message content")
521
        }
522
3x
        msg.Content = contentObj
523
3x

524
3x
        if questionIDPtr != nil {
525
            msg.QuestionId = questionIDPtr
526
        }
527

528
        // Content is retrieved directly as text using ->> operator
529

530
        // Set conversation title for search results
531
3x
        msg.ConversationTitle = &conversationTitle
532
3x

533
3x
        messages = append(messages, msg)
534
    }
535

536
2x
    if err := rows.Err(); err != nil {
537
        return nil, 0, contextutils.WrapError(err, "error iterating search results")
538
    }
539

540
2x
    return messages, total, nil
541
}
542

543
// SearchConversations searches across all conversations for a user
544
func (s *ConversationService) SearchConversations(ctx context.Context, userID uint, query string, limit, offset int) ([]api.Conversation, int, error) {
545
    // Clean and prepare the search query
546
    searchQuery := strings.TrimSpace(query)
547
    if searchQuery == "" {
548
        return nil, 0, contextutils.ErrorWithContextf("search query cannot be empty")
549
    }
550

551
    // Search in both conversation titles and message content
552
    searchTerm := fmt.Sprintf("%%%s%%", strings.ToLower(searchQuery))
553

554
    // Get total count of matching conversations
555
    countQuery := `
556
        SELECT COUNT(DISTINCT c.id)
557
        FROM ai_conversations c
558
        LEFT JOIN ai_chat_messages m ON c.id = m.conversation_id
559
        WHERE c.user_id = $1
560
        AND (LOWER(c.title) LIKE $2 OR LOWER(m.answer_json::text) LIKE $2)`
561

562
    var total int
563
    err := s.db.QueryRowContext(ctx, countQuery, userID, searchTerm).Scan(&total)
564
    if err != nil {
565
        return nil, 0, contextutils.WrapError(err, "failed to count search results")
566
    }
567

568
    // Get conversations with their latest message info
569
    querySQL := `
570
        SELECT DISTINCT c.id, c.title, c.created_at, c.updated_at,
571
               (SELECT COUNT(*) FROM ai_chat_messages WHERE conversation_id = c.id) as message_count,
572
               (SELECT answer_json::text FROM ai_chat_messages WHERE conversation_id = c.id ORDER BY created_at ASC LIMIT 1) as first_message,
573
               (SELECT answer_json::text FROM ai_chat_messages WHERE conversation_id = c.id ORDER BY created_at DESC LIMIT 1) as last_message
574
        FROM ai_conversations c
575
        LEFT JOIN ai_chat_messages m ON c.id = m.conversation_id
576
        WHERE c.user_id = $1
577
        AND (LOWER(c.title) LIKE $2 OR LOWER(m.answer_json::text) LIKE $2)
578
        ORDER BY c.updated_at DESC
579
        LIMIT $3 OFFSET $4`
580

581
    rows, err := s.db.QueryContext(ctx, querySQL, userID, searchTerm, limit, offset)
582
    if err != nil {
583
        return nil, 0, contextutils.WrapError(err, "failed to search conversations")
584
    }
585
    defer func() { _ = rows.Close() }()
586

587
    var conversations []api.Conversation
588
    for rows.Next() {
589
        var conv api.Conversation
590
        var firstMessagePtr, lastMessagePtr *string
591
        var messageCount int
592

593
        err := rows.Scan(
594
            &conv.Id,
595
            &conv.Title,
596
            &conv.CreatedAt,
597
            &conv.UpdatedAt,
598
            &messageCount,
599
            &firstMessagePtr,
600
            &lastMessagePtr,
601
        )
602
        if err != nil {
603
            return nil, 0, contextutils.WrapError(err, "failed to scan search result")
604
        }
605

606
        // Set the preview message to the last message if available, otherwise the first message
607
        previewMessage := ""
608
        if lastMessagePtr != nil {
609
            previewMessage = *lastMessagePtr
610
        } else if firstMessagePtr != nil {
611
            previewMessage = *firstMessagePtr
612
        }
613

614
        // For search results, we need to create a minimal content object
615
        contentObj := struct {
616
            Text *string `json:"text,omitempty"`
617
        }{
618
            Text: &previewMessage,
619
        }
620

621
        // Add preview_message field for frontend compatibility
622
        conv.Messages = &[]api.ChatMessage{
623
            {
624
                Content: contentObj,
625
            },
626
        }
627

628
        conversations = append(conversations, conv)
629
    }
630

631
    if err := rows.Err(); err != nil {
632
        return nil, 0, contextutils.WrapError(err, "error iterating search results")
633
    }
634

635
    return conversations, total, nil
636
}
637

638
// GetBookmarkedMessages retrieves all bookmarked messages for a user
639
func (s *ConversationService) GetBookmarkedMessages(ctx context.Context, userID uint, query string, limit, offset int) ([]api.ChatMessage, int, error) {
640
    // Clean and prepare the search query if provided
641
    searchTerm := "%"
642
    if query != "" {
643
        searchQuery := strings.TrimSpace(query)
644
        searchTerm = fmt.Sprintf("%%%s%%", strings.ToLower(searchQuery))
645
    }
646

647
    // Get total count of bookmarked messages
648
    countQuery := `
649
        SELECT COUNT(*)
650
        FROM ai_chat_messages m
651
        JOIN ai_conversations c ON m.conversation_id = c.id
652
        WHERE c.user_id = $1 AND m.bookmarked = true AND LOWER(m.answer_json::text) LIKE $2`
653

654
    var total int
655
    err := s.db.QueryRowContext(ctx, countQuery, userID, searchTerm).Scan(&total)
656
    if err != nil {
657
        return nil, 0, contextutils.WrapError(err, "failed to count bookmarked messages")
658
    }
659

660
    // Get bookmarked messages with conversation titles
661
    querySQL := `
662
        SELECT m.id, m.conversation_id, m.question_id, m.role, m.answer_json::text, m.bookmarked, m.created_at, m.updated_at, c.title
663
        FROM ai_chat_messages m
664
        JOIN ai_conversations c ON m.conversation_id = c.id
665
        WHERE c.user_id = $1 AND m.bookmarked = true AND LOWER(m.answer_json::text) LIKE $2
666
        ORDER BY m.created_at DESC
667
        LIMIT $3 OFFSET $4`
668

669
    rows, err := s.db.QueryContext(ctx, querySQL, userID, searchTerm, limit, offset)
670
    if err != nil {
671
        return nil, 0, contextutils.WrapError(err, "failed to get bookmarked messages")
672
    }
673
    defer func() { _ = rows.Close() }()
674

675
    var messages []api.ChatMessage
676
    for rows.Next() {
677
        var msg api.ChatMessage
678
        var questionIDPtr *int
679
        var conversationTitle string
680

681
        var answerBytes []byte
682
        err := rows.Scan(
683
            &msg.Id,
684
            &msg.ConversationId,
685
            &questionIDPtr,
686
            &msg.Role,
687
            &answerBytes,
688
            &msg.Bookmarked,
689
            &msg.CreatedAt,
690
            &msg.UpdatedAt,
691
            &conversationTitle,
692
        )
693
        if err != nil {
694
            return nil, 0, contextutils.WrapError(err, "failed to scan bookmarked message")
695
        }
696

697
        // Content is stored as an object, unmarshal accordingly
698
        var contentObj struct {
699
            Text *string `json:"text,omitempty"`
700
        }
701
        err = json.Unmarshal(answerBytes, &contentObj)
702
        if err != nil {
703
            return nil, 0, contextutils.WrapError(err, "failed to unmarshal message content")
704
        }
705
        msg.Content = contentObj
706

707
        if questionIDPtr != nil {
708
            msg.QuestionId = questionIDPtr
709
        }
710

711
        // Set conversation title for display
712
        msg.ConversationTitle = &conversationTitle
713

714
        messages = append(messages, msg)
715
    }
716

717
    if err := rows.Err(); err != nil {
718
        return nil, 0, contextutils.WrapError(err, "error iterating bookmarked messages")
719
    }
720

721
    return messages, total, nil
722
}
723


			
quizapp internal services worker_service.go
62.2%
Statements
265/426
1
package services
2

3
import (
4
    "context"
5
    "database/sql"
6
    "fmt"
7
    "time"
8

9
    "quizapp/internal/api"
10
    "quizapp/internal/models"
11
    "quizapp/internal/observability"
12
    contextutils "quizapp/internal/utils"
13

14
    "go.opentelemetry.io/otel"
15
    "go.opentelemetry.io/otel/attribute"
16
    "go.opentelemetry.io/otel/codes"
17
    "go.opentelemetry.io/otel/trace"
18
)
19

20
// DailyQuestionServiceInterface defines the interface for daily question operations
21
type DailyQuestionServiceInterface interface {
22
    AssignDailyQuestions(ctx context.Context, userID int, date time.Time) error
23
    RegenerateDailyQuestions(ctx context.Context, userID int, date time.Time) error
24
    GetDailyQuestions(ctx context.Context, userID int, date time.Time) ([]*models.DailyQuestionAssignmentWithQuestion, error)
25
    MarkQuestionCompleted(ctx context.Context, userID, questionID int, date time.Time) error
26
    ResetQuestionCompleted(ctx context.Context, userID, questionID int, date time.Time) error
27
    SubmitDailyQuestionAnswer(ctx context.Context, userID, questionID int, date time.Time, userAnswerIndex int) (*api.AnswerResponse, error)
28
    GetAvailableDates(ctx context.Context, userID int) ([]time.Time, error)
29
    GetDailyProgress(ctx context.Context, userID int, date time.Time) (*models.DailyProgress, error)
30
    GetDailyQuestionsCount(ctx context.Context, userID int, date time.Time) (int, error)
31
    GetCompletedDailyQuestionsCount(ctx context.Context, userID int, date time.Time) (int, error)
32
    GetQuestionHistory(ctx context.Context, userID, questionID, days int) ([]*models.DailyQuestionHistory, error)
33
}
34

35
// DailyQuestionService implements daily question assignment and management
36
type DailyQuestionService struct {
37
    db              *sql.DB
38
    logger          *observability.Logger
39
    questionService QuestionServiceInterface
40
    learningService LearningServiceInterface
41
}
42

43
// NewDailyQuestionService creates a new DailyQuestionService instance
44
15x
func NewDailyQuestionService(db *sql.DB, logger *observability.Logger, questionService QuestionServiceInterface, learningService LearningServiceInterface) *DailyQuestionService {
45
15x
    return &DailyQuestionService{
46
15x
        db:              db,
47
15x
        logger:          logger,
48
15x
        questionService: questionService,
49
15x
        learningService: learningService,
50
15x
    }
51
15x
}
52

53
// AssignDailyQuestions assigns 10 random questions to a user for a specific date
54
33x
func (s *DailyQuestionService) AssignDailyQuestions(ctx context.Context, userID int, date time.Time) (err error) {
55
33x
    ctx, span := otel.Tracer("daily-question-service").Start(ctx, "AssignDailyQuestions",
56
33x
        trace.WithAttributes(
57
33x
            attribute.Int("user.id", userID),
58
33x
            attribute.String("date", date.Format("2006-01-02")),
59
33x
        ),
60
33x
    )
61
33x
    defer func() {
62
33x
        if err != nil {
63
2x
            span.RecordError(err, trace.WithStackTrace(true))
64
2x
            span.SetStatus(codes.Error, err.Error())
65
2x
        }
66
33x
        span.End()
67
    }()
68

69
    // Get user to determine language and level preferences
70
33x
    user, err := s.getUserByID(ctx, userID)
71
33x
    if err != nil {
72
        span.RecordError(err)
73
        return contextutils.WrapError(err, "failed to get user")
74
    }
75

76
33x
    if user == nil {
77
        return contextutils.ErrorWithContextf("user not found: %d", userID)
78
    }
79
33x
    span.SetAttributes(attribute.String("user.name", user.Username))
80
33x

81
33x
    language := user.PreferredLanguage.String
82
33x
    level := user.CurrentLevel.String
83
33x

84
33x
    if language == "" || level == "" {
85
1x
        return contextutils.ErrorWithContextf("user missing language or level preferences")
86
1x
    }
87

88
    // Get user's daily goal from learning preferences
89
32x
    prefs, perr := s.learningService.GetUserLearningPreferences(ctx, userID)
90
32x
    if perr != nil {
91
        span.RecordError(perr)
92
        return contextutils.WrapError(perr, "failed to get user learning preferences")
93
    }
94
32x
    goal := 10
95
32x
    if prefs != nil && prefs.DailyGoal > 0 {
96
32x
        goal = prefs.DailyGoal
97
32x
    }
98

99
    // Check existing assignments and only fill missing slots up to the user's goal
100
32x
    existingCount, err := s.GetDailyQuestionsCount(ctx, userID, date)
101
32x
    if err != nil {
102
        span.RecordError(err)
103
        return contextutils.WrapError(err, "failed to check existing assignments")
104
    }
105
32x
    if existingCount >= goal {
106
4x
        // s.logger.Info(ctx, "Daily questions already assigned for date", map[string]interface{}{
107
4x
        //     "user_id": userID,
108
4x
        //     "date":    date.Format("2006-01-02"),
109
4x
        //     "count":   existingCount,
110
4x
        //     "goal":    goal,
111
4x
        // })
112
4x
        return nil // Already assigned
113
4x
    }
114

115
    // Request more candidates than strictly needed to allow filtering out already-assigned questions
116
28x
    buffer := 10 // request this many extra candidates beyond the user's goal
117
28x
    reqLimit := goal + buffer
118
28x

119
28x
    // Get adaptive questions using an expanded limit so we can filter and still meet goal
120
28x
    questionsWithStats, err := s.questionService.GetAdaptiveQuestionsForDaily(ctx, userID, language, level, reqLimit)
121
28x
    if err != nil {
122
        span.RecordError(err)
123
        return contextutils.WrapError(err, "failed to get adaptive questions for assignment")
124
    }
125

126
28x
    if len(questionsWithStats) == 0 {
127
1x
        // Gather diagnostics to explain why no questions were available
128
1x
        var candidateIDs []int
129
1x
        candidateCount := 0
130
1x
        totalMatching := 0
131
1x
        if s.questionService != nil {
132
1x
            if candidates, qerr := s.questionService.GetAdaptiveQuestionsForDaily(ctx, userID, language, level, 50); qerr == nil && candidates != nil {
133
1x
                candidateCount = len(candidates)
134
1x
                for i, q := range candidates {
135
                    if i >= 10 {
136
                        break
137
                    }
138
                    if q != nil {
139
                        candidateIDs = append(candidateIDs, q.ID)
140
                    }
141
                }
142
            }
143
1x
            if _, total, terr := s.questionService.GetAllQuestionsPaginated(ctx, 1, 1, "", "", "", language, level, nil); terr == nil {
144
1x
                totalMatching = total
145
1x
            }
146
        }
147

148
1x
        return &NoQuestionsAvailableError{
149
1x
            Language:       language,
150
1x
            Level:          level,
151
1x
            CandidateIDs:   candidateIDs,
152
1x
            CandidateCount: candidateCount,
153
1x
            TotalMatching:  totalMatching,
154
1x
        }
155
    }
156

157
    // Filter out questions that are already assigned for this user/date to
158
    // avoid selecting already-inserted questions and thus underfilling the goal.
159
27x
    assignedIDs := make(map[int]bool)
160
27x
    rows, qerr := s.db.QueryContext(ctx, `SELECT question_id FROM daily_question_assignments WHERE user_id = $1 AND assignment_date = $2`, userID, date)
161
27x
    if qerr == nil {
162
27x
        defer func() {
163
27x
            if closeErr := rows.Close(); closeErr != nil {
164
                s.logger.Warn(ctx, "Failed to close rows", map[string]interface{}{"error": closeErr.Error()})
165
            }
166
        }()
167
27x
        for rows.Next() {
168
44x
            var qid int
169
44x
            if err := rows.Scan(&qid); err == nil {
170
44x
                assignedIDs[qid] = true
171
44x
            }
172
        }
173
    }
174

175
    // Convert QuestionWithStats to Question for assignment, skipping already-assigned
176
27x
    var questions []models.Question
177
27x
    for _, qws := range questionsWithStats {
178
548x
        if qws == nil || qws.Question == nil {
179
            continue
180
        }
181
548x
        if assignedIDs[qws.ID] {
182
35x
            // already assigned for this date, skip
183
35x
            continue
184
        }
185
513x
        questions = append(questions, *qws.Question)
186
    }
187

188
    // Only insert up to the number of slots we need to fill
189
27x
    toAssign := goal - existingCount
190
27x
    if toAssign < 0 {
191
        toAssign = 0
192
    }
193
27x
    if len(questions) > toAssign {
194
20x
        questions = questions[:toAssign]
195
20x
    }
196

197
    // Begin transaction
198
27x
    tx, err := s.db.BeginTx(ctx, nil)
199
27x
    if err != nil {
200
        span.RecordError(err)
201
        return contextutils.WrapError(err, "failed to begin transaction")
202
    }
203
27x
    defer func() {
204
27x
        if err != nil {
205
            if rollbackErr := tx.Rollback(); rollbackErr != nil {
206
                s.logger.Error(ctx, "Failed to rollback transaction", rollbackErr, map[string]interface{}{
207
                    "user_id": userID,
208
                    "date":    date.Format("2006-01-02"),
209
                })
210
            }
211
        }
212
    }()
213

214
    // Insert assignments (idempotent via conditional INSERT to avoid duplicate rows)
215
27x
    insertQuery := `
216
27x
        INSERT INTO daily_question_assignments (user_id, question_id, assignment_date, created_at)
217
27x
        SELECT $1, $2, $3, $4
218
27x
        WHERE NOT EXISTS (
219
27x
            SELECT 1 FROM daily_question_assignments WHERE user_id = $1 AND question_id = $2 AND assignment_date = $3
220
27x
        )
221
27x
    `
222
27x

223
27x
    for _, question := range questions {
224
321x
        _, err = tx.ExecContext(ctx, insertQuery, userID, question.ID, date, time.Now())
225
321x
        if err != nil {
226
            span.RecordError(err)
227
            return contextutils.WrapError(err, "failed to insert assignment")
228
        }
229
    }
230

231
    // Commit transaction
232
27x
    err = tx.Commit()
233
27x
    if err != nil {
234
        span.RecordError(err)
235
        return contextutils.WrapError(err, "failed to commit transaction")
236
    }
237

238
27x
    s.logger.Info(ctx, "Daily questions assigned successfully", map[string]interface{}{
239
27x
        "user_id": userID,
240
27x
        "date":    date.Format("2006-01-02"),
241
27x
        "count":   len(questions),
242
27x
    })
243
27x

244
27x
    return nil
245
}
246

247
// RegenerateDailyQuestions clears existing daily question assignments and creates new ones for a user and date
248
3x
func (s *DailyQuestionService) RegenerateDailyQuestions(ctx context.Context, userID int, date time.Time) (err error) {
249
3x
    ctx, span := otel.Tracer("daily-question-service").Start(ctx, "RegenerateDailyQuestions",
250
3x
        trace.WithAttributes(
251
3x
            attribute.Int("user.id", userID),
252
3x
            attribute.String("date", date.Format("2006-01-02")),
253
3x
        ),
254
3x
    )
255
3x
    defer func() {
256
3x
        if err != nil {
257
            span.RecordError(err, trace.WithStackTrace(true))
258
            span.SetStatus(codes.Error, err.Error())
259
        }
260
3x
        span.End()
261
    }()
262

263
    // Get user to determine language and level preferences
264
3x
    user, err := s.getUserByID(ctx, userID)
265
3x
    if err != nil {
266
        span.RecordError(err)
267
        return contextutils.WrapError(err, "failed to get user")
268
    }
269

270
3x
    if user == nil {
271
        return contextutils.ErrorWithContextf("user not found: %d", userID)
272
    }
273

274
3x
    language := user.PreferredLanguage.String
275
3x
    level := user.CurrentLevel.String
276
3x

277
3x
    if language == "" || level == "" {
278
        return contextutils.ErrorWithContextf("user missing language or level preferences")
279
    }
280

281
    // Get user's daily goal from learning preferences
282
3x
    prefs, perr := s.learningService.GetUserLearningPreferences(ctx, userID)
283
3x
    if perr != nil {
284
        span.RecordError(perr)
285
        return contextutils.WrapError(perr, "failed to get user learning preferences")
286
    }
287
3x
    goal := 10
288
3x
    if prefs != nil && prefs.DailyGoal > 0 {
289
3x
        goal = prefs.DailyGoal
290
3x
    }
291

292
    // Request more candidates than strictly needed to allow filtering out already-assigned questions
293
3x
    buffer := 10 // request this many extra candidates beyond the user's goal
294
3x
    reqLimit := goal + buffer
295
3x

296
3x
    // Get adaptive questions using an expanded limit so we can filter and still meet goal
297
3x
    questionsWithStats, err := s.questionService.GetAdaptiveQuestionsForDaily(ctx, userID, language, level, reqLimit)
298
3x
    if err != nil {
299
        span.RecordError(err)
300
        return contextutils.WrapError(err, "failed to get adaptive questions for assignment")
301
    }
302

303
3x
    if len(questionsWithStats) == 0 {
304
        // Gather diagnostics to explain why no questions were available
305
        var candidateIDs []int
306
        candidateCount := 0
307
        totalMatching := 0
308
        if s.questionService != nil {
309
            if candidates, qerr := s.questionService.GetAdaptiveQuestionsForDaily(ctx, userID, language, level, 50); qerr == nil && candidates != nil {
310
                candidateCount = len(candidates)
311
                for i, q := range candidates {
312
                    if i >= 10 {
313
                        break
314
                    }
315
                    if q != nil {
316
                        candidateIDs = append(candidateIDs, q.ID)
317
                    }
318
                }
319
            }
320
            if _, total, terr := s.questionService.GetAllQuestionsPaginated(ctx, 1, 1, "", "", "", language, level, nil); terr == nil {
321
                totalMatching = total
322
            }
323
        }
324

325
        return &NoQuestionsAvailableError{
326
            Language:       language,
327
            Level:          level,
328
            CandidateIDs:   candidateIDs,
329
            CandidateCount: candidateCount,
330
            TotalMatching:  totalMatching,
331
        }
332
    }
333

334
    // Convert QuestionWithStats to Question for assignment
335
3x
    var questions []models.Question
336
3x
    for _, qws := range questionsWithStats {
337
47x
        questions = append(questions, *qws.Question)
338
47x
    }
339

340
    // Begin transaction
341
3x
    tx, err := s.db.BeginTx(ctx, nil)
342
3x
    if err != nil {
343
        span.RecordError(err)
344
        return contextutils.WrapError(err, "failed to begin transaction")
345
    }
346
3x
    defer func() {
347
3x
        if err != nil {
348
            if rollbackErr := tx.Rollback(); rollbackErr != nil {
349
                s.logger.Error(ctx, "Failed to rollback transaction", rollbackErr, map[string]interface{}{
350
                    "user_id": userID,
351
                    "date":    date.Format("2006-01-02"),
352
                })
353
            }
354
        }
355
    }()
356

357
    // First, delete existing assignments for this user and date
358
3x
    deleteQuery := `DELETE FROM daily_question_assignments WHERE user_id = $1 AND assignment_date = $2`
359
3x
    _, err = tx.ExecContext(ctx, deleteQuery, userID, date)
360
3x
    if err != nil {
361
        span.RecordError(err)
362
        return contextutils.WrapError(err, "failed to delete existing assignments")
363
    }
364

365
    // Insert new assignments
366
3x
    insertQuery := `
367
3x
        INSERT INTO daily_question_assignments (user_id, question_id, assignment_date, created_at)
368
3x
        VALUES ($1, $2, $3, $4)
369
3x
    `
370
3x

371
3x
    stmt, err := tx.PrepareContext(ctx, insertQuery)
372
3x
    if err != nil {
373
        span.RecordError(err)
374
        return contextutils.WrapError(err, "failed to prepare statement")
375
    }
376
3x
    defer func() {
377
3x
        if closeErr := stmt.Close(); closeErr != nil {
378
            s.logger.Error(ctx, "Failed to close statement", closeErr, map[string]interface{}{
379
                "user_id": userID,
380
                "date":    date.Format("2006-01-02"),
381
            })
382
        }
383
    }()
384

385
    // Only assign up to the goal amount
386
3x
    assignedCount := 0
387
3x
    for _, question := range questions {
388
35x
        if assignedCount >= goal {
389
3x
            break
390
        }
391
32x
        _, err = stmt.ExecContext(ctx, userID, question.ID, date, time.Now())
392
32x
        if err != nil {
393
            span.RecordError(err)
394
            return contextutils.WrapError(err, "failed to insert assignment")
395
        }
396
32x
        assignedCount++
397
    }
398

399
    // Commit transaction
400
3x
    err = tx.Commit()
401
3x
    if err != nil {
402
        span.RecordError(err)
403
        return contextutils.WrapError(err, "failed to commit transaction")
404
    }
405

406
3x
    s.logger.Info(ctx, "Daily questions regenerated successfully", map[string]interface{}{
407
3x
        "user_id": userID,
408
3x
        "date":    date.Format("2006-01-02"),
409
3x
        "count":   len(questions),
410
3x
    })
411
3x

412
3x
    return nil
413
}
414

415
// GetDailyQuestions retrieves all daily questions for a user on a specific date
416
17x
func (s *DailyQuestionService) GetDailyQuestions(ctx context.Context, userID int, date time.Time) (result0 []*models.DailyQuestionAssignmentWithQuestion, err error) {
417
17x
    ctx, span := otel.Tracer("daily-question-service").Start(ctx, "GetDailyQuestions",
418
17x
        trace.WithAttributes(
419
17x
            attribute.Int("user.id", userID),
420
17x
            attribute.String("date", date.Format("2006-01-02")),
421
17x
        ),
422
17x
    )
423
17x
    defer func() {
424
17x
        if err != nil {
425
            span.RecordError(err, trace.WithStackTrace(true))
426
            span.SetStatus(codes.Error, err.Error())
427
        }
428
17x
        span.End()
429
    }()
430

431
17x
    query := `
432
17x
        SELECT dqa.id, dqa.user_id, dqa.question_id, dqa.assignment_date,
433
17x
               dqa.is_completed, dqa.completed_at, dqa.created_at,
434
17x
               dqa.user_answer_index, dqa.submitted_at,
435
17x
               q.id, q.type, q.language, q.level, q.difficulty_score, q.content,
436
17x
               q.correct_answer, q.explanation, q.created_at, q.status,
437
17x
               q.topic_category, q.grammar_focus, q.vocabulary_domain, q.scenario,
438
17x
               q.style_modifier, q.difficulty_modifier, q.time_context,
439
17x
               -- Daily shown count per user: how many times this user has seen this question in Daily across all dates
440
17x
               (SELECT COUNT(*) FROM daily_question_assignments dqa_all WHERE dqa_all.question_id = dqa.question_id AND dqa_all.user_id = dqa.user_id) AS daily_shown_count,
441
17x
               -- Per-user correctness stats across all time
442
17x
               COALESCE((SELECT COUNT(*) FROM user_responses ur WHERE ur.user_id = dqa.user_id AND ur.question_id = dqa.question_id), 0) AS user_total_responses,
443
17x
               COALESCE((SELECT COUNT(*) FROM user_responses ur WHERE ur.user_id = dqa.user_id AND ur.question_id = dqa.question_id AND ur.is_correct = TRUE), 0) AS user_correct_count,
444
17x
               COALESCE((SELECT COUNT(*) FROM user_responses ur WHERE ur.user_id = dqa.user_id AND ur.question_id = dqa.question_id AND ur.is_correct = FALSE), 0) AS user_incorrect_count
445
17x
        FROM daily_question_assignments dqa
446
17x
        JOIN questions q ON dqa.question_id = q.id
447
17x
        WHERE dqa.user_id = $1 AND dqa.assignment_date = $2
448
17x
        ORDER BY dqa.created_at ASC
449
17x
    `
450
17x

451
17x
    rows, err := s.db.QueryContext(ctx, query, userID, date)
452
17x
    if err != nil {
453
        span.RecordError(err)
454
        return nil, contextutils.WrapError(err, "failed to query daily questions")
455
    }
456
17x
    defer func() {
457
17x
        if closeErr := rows.Close(); closeErr != nil {
458
            s.logger.Error(ctx, "Failed to close rows", closeErr, map[string]interface{}{
459
                "user_id": userID,
460
                "date":    date.Format("2006-01-02"),
461
            })
462
        }
463
    }()
464

465
17x
    var assignments []*models.DailyQuestionAssignmentWithQuestion
466
17x
    for rows.Next() {
467
170x
        var assignment models.DailyQuestionAssignmentWithQuestion
468
170x
        var question models.Question
469
170x
        var contentJSON string
470
170x

471
170x
        err := rows.Scan(
472
170x
            &assignment.ID, &assignment.UserID, &assignment.QuestionID, &assignment.AssignmentDate,
473
170x
            &assignment.IsCompleted, &assignment.CompletedAt, &assignment.CreatedAt,
474
170x
            &assignment.UserAnswerIndex, &assignment.SubmittedAt,
475
170x
            &question.ID, &question.Type, &question.Language, &question.Level, &question.DifficultyScore,
476
170x
            &contentJSON, &question.CorrectAnswer, &question.Explanation, &question.CreatedAt, &question.Status,
477
170x
            &question.TopicCategory, &question.GrammarFocus, &question.VocabularyDomain, &question.Scenario,
478
170x
            &question.StyleModifier, &question.DifficultyModifier, &question.TimeContext,
479
170x
            &assignment.DailyShownCount,
480
170x
            &assignment.UserTotalResponses,
481
170x
            &assignment.UserCorrectCount,
482
170x
            &assignment.UserIncorrectCount,
483
170x
        )
484
170x
        if err != nil {
485
            s.logger.Error(ctx, "Failed to scan daily question assignment", err, map[string]interface{}{
486
                "user_id": userID,
487
                "date":    date.Format("2006-01-02"),
488
            })
489
            continue
490
        }
491

492
        // Unmarshal the JSON content
493
170x
        if err := question.UnmarshalContentFromJSON(contentJSON); err != nil {
494
            s.logger.Error(ctx, "Failed to unmarshal question content", err, map[string]interface{}{
495
                "user_id": userID,
496
                "date":    date.Format("2006-01-02"),
497
                "content": contentJSON,
498
            })
499
            continue
500
        }
501

502
170x
        assignment.Question = &question
503
170x
        assignments = append(assignments, &assignment)
504
    }
505

506
17x
    if err = rows.Err(); err != nil {
507
        span.RecordError(err)
508
        return nil, contextutils.WrapError(err, "error iterating over rows")
509
    }
510

511
17x
    return assignments, nil
512
}
513

514
// MarkQuestionCompleted marks a daily question as completed
515
5x
func (s *DailyQuestionService) MarkQuestionCompleted(ctx context.Context, userID, questionID int, date time.Time) (err error) {
516
5x
    ctx, span := otel.Tracer("daily-question-service").Start(ctx, "MarkQuestionCompleted",
517
5x
        trace.WithAttributes(
518
5x
            attribute.Int("user.id", userID),
519
5x
            attribute.Int("question.id", questionID),
520
5x
            attribute.String("date", date.Format("2006-01-02")),
521
5x
        ),
522
5x
    )
523
5x
    defer func() {
524
5x
        if err != nil {
525
            span.RecordError(err, trace.WithStackTrace(true))
526
            span.SetStatus(codes.Error, err.Error())
527
        }
528
5x
        span.End()
529
    }()
530

531
5x
    query := `
532
5x
        UPDATE daily_question_assignments
533
5x
        SET is_completed = true, completed_at = $1
534
5x
        WHERE user_id = $2 AND question_id = $3 AND assignment_date = $4
535
5x
    `
536
5x

537
5x
    result, err := s.db.ExecContext(ctx, query, time.Now(), userID, questionID, date)
538
5x
    if err != nil {
539
        span.RecordError(err)
540
        return contextutils.WrapError(err, "failed to mark question as completed")
541
    }
542

543
5x
    rowsAffected, err := result.RowsAffected()
544
5x
    if err != nil {
545
        span.RecordError(err)
546
        return contextutils.WrapError(err, "failed to get rows affected")
547
    }
548

549
5x
    if rowsAffected == 0 {
550
        return contextutils.ErrAssignmentNotFound
551
    }
552

553
5x
    s.logger.Info(ctx, "Question marked as completed", map[string]interface{}{
554
5x
        "user_id":     userID,
555
5x
        "question_id": questionID,
556
5x
        "date":        date.Format("2006-01-02"),
557
5x
    })
558
5x

559
5x
    return nil
560
}
561

562
// ResetQuestionCompleted resets a daily question to not completed
563
1x
func (s *DailyQuestionService) ResetQuestionCompleted(ctx context.Context, userID, questionID int, date time.Time) (err error) {
564
1x
    ctx, span := otel.Tracer("daily-question-service").Start(ctx, "ResetQuestionCompleted",
565
1x
        trace.WithAttributes(
566
1x
            attribute.Int("user.id", userID),
567
1x
            attribute.Int("question.id", questionID),
568
1x
            attribute.String("date", date.Format("2006-01-02")),
569
1x
        ),
570
1x
    )
571
1x
    defer func() {
572
1x
        if err != nil {
573
            span.RecordError(err, trace.WithStackTrace(true))
574
            span.SetStatus(codes.Error, err.Error())
575
        }
576
1x
        span.End()
577
    }()
578

579
1x
    query := `
580
1x
        UPDATE daily_question_assignments
581
1x
        SET is_completed = false, completed_at = NULL, user_answer_index = NULL, submitted_at = NULL
582
1x
        WHERE user_id = $1 AND question_id = $2 AND assignment_date = $3
583
1x
    `
584
1x

585
1x
    result, err := s.db.ExecContext(ctx, query, userID, questionID, date)
586
1x
    if err != nil {
587
        span.RecordError(err)
588
        return contextutils.WrapError(err, "failed to reset question completion")
589
    }
590

591
1x
    rowsAffected, err := result.RowsAffected()
592
1x
    if err != nil {
593
        span.RecordError(err)
594
        return contextutils.WrapError(err, "failed to get rows affected")
595
    }
596

597
1x
    if rowsAffected == 0 {
598
        return contextutils.ErrAssignmentNotFound
599
    }
600

601
1x
    s.logger.Info(ctx, "Question reset to not completed", map[string]interface{}{
602
1x
        "user_id":     userID,
603
1x
        "question_id": questionID,
604
1x
        "date":        date.Format("2006-01-02"),
605
1x
    })
606
1x

607
1x
    return nil
608
}
609

610
// GetAvailableDates retrieves all dates for which a user has daily question assignments
611
1x
func (s *DailyQuestionService) GetAvailableDates(ctx context.Context, userID int) (result0 []time.Time, err error) {
612
1x
    ctx, span := otel.Tracer("daily-question-service").Start(ctx, "GetAvailableDates",
613
1x
        trace.WithAttributes(
614
1x
            attribute.Int("user.id", userID),
615
1x
        ),
616
1x
    )
617
1x
    defer func() {
618
1x
        if err != nil {
619
            span.RecordError(err, trace.WithStackTrace(true))
620
            span.SetStatus(codes.Error, err.Error())
621
        }
622
1x
        span.End()
623
    }()
624

625
1x
    query := `
626
1x
        SELECT DISTINCT assignment_date
627
1x
        FROM daily_question_assignments
628
1x
        WHERE user_id = $1
629
1x
        ORDER BY assignment_date DESC
630
1x
    `
631
1x

632
1x
    rows, err := s.db.QueryContext(ctx, query, userID)
633
1x
    if err != nil {
634
        span.RecordError(err)
635
        return nil, contextutils.WrapError(err, "failed to query available dates")
636
    }
637
1x
    defer func() {
638
1x
        if closeErr := rows.Close(); closeErr != nil {
639
            s.logger.Error(ctx, "Failed to close rows", closeErr, map[string]interface{}{
640
                "user_id": userID,
641
            })
642
        }
643
    }()
644

645
1x
    var dates []time.Time
646
1x
    for rows.Next() {
647
3x
        var date time.Time
648
3x
        err := rows.Scan(&date)
649
3x
        if err != nil {
650
            s.logger.Error(ctx, "Failed to scan date", err, map[string]interface{}{
651
                "user_id": userID,
652
            })
653
            continue
654
        }
655
3x
        dates = append(dates, date)
656
    }
657

658
1x
    if err = rows.Err(); err != nil {
659
        span.RecordError(err)
660
        return nil, contextutils.WrapError(err, "error iterating over rows")
661
    }
662

663
1x
    return dates, nil
664
}
665

666
// GetDailyProgress retrieves the progress for a specific date
667
2x
func (s *DailyQuestionService) GetDailyProgress(ctx context.Context, userID int, date time.Time) (result0 *models.DailyProgress, err error) {
668
2x
    ctx, span := otel.Tracer("daily-question-service").Start(ctx, "GetDailyProgress",
669
2x
        trace.WithAttributes(
670
2x
            attribute.Int("user.id", userID),
671
2x
            attribute.String("date", date.Format("2006-01-02")),
672
2x
        ),
673
2x
    )
674
2x
    defer func() {
675
2x
        if err != nil {
676
            span.RecordError(err, trace.WithStackTrace(true))
677
            span.SetStatus(codes.Error, err.Error())
678
        }
679
2x
        span.End()
680
    }()
681

682
2x
    query := `
683
2x
        SELECT
684
2x
            COUNT(*) as total,
685
2x
            COUNT(CASE WHEN is_completed = true THEN 1 END) as completed
686
2x
        FROM daily_question_assignments
687
2x
        WHERE user_id = $1 AND assignment_date = $2
688
2x
    `
689
2x

690
2x
    var total, completed int
691
2x
    err = s.db.QueryRowContext(ctx, query, userID, date).Scan(&total, &completed)
692
2x
    if err != nil {
693
        return nil, contextutils.WrapError(err, "failed to get daily progress")
694
    }
695

696
2x
    progress := &models.DailyProgress{
697
2x
        Date:      date,
698
2x
        Completed: completed,
699
2x
        Total:     total,
700
2x
    }
701
2x

702
2x
    return progress, nil
703
}
704

705
// GetDailyQuestionsCount retrieves the total number of questions assigned for a date
706
32x
func (s *DailyQuestionService) GetDailyQuestionsCount(ctx context.Context, userID int, date time.Time) (result0 int, err error) {
707
32x
    ctx, span := otel.Tracer("daily-question-service").Start(ctx, "GetDailyQuestionsCount",
708
32x
        trace.WithAttributes(
709
32x
            attribute.Int("user.id", userID),
710
32x
            attribute.String("date", date.Format("2006-01-02")),
711
32x
        ),
712
32x
    )
713
32x
    defer func() {
714
32x
        if err != nil {
715
            span.RecordError(err, trace.WithStackTrace(true))
716
            span.SetStatus(codes.Error, err.Error())
717
        }
718
32x
        span.End()
719
    }()
720

721
32x
    query := `
722
32x
        SELECT COUNT(*)
723
32x
        FROM daily_question_assignments
724
32x
        WHERE user_id = $1 AND assignment_date = $2
725
32x
    `
726
32x

727
32x
    var count int
728
32x
    err = s.db.QueryRowContext(ctx, query, userID, date).Scan(&count)
729
32x
    if err != nil {
730
        return 0, contextutils.WrapError(err, "failed to get daily questions count")
731
    }
732

733
32x
    return count, nil
734
}
735

736
// GetCompletedDailyQuestionsCount retrieves the number of completed questions for a date
737
func (s *DailyQuestionService) GetCompletedDailyQuestionsCount(ctx context.Context, userID int, date time.Time) (result0 int, err error) {
738
    ctx, span := otel.Tracer("daily-question-service").Start(ctx, "GetCompletedDailyQuestionsCount",
739
        trace.WithAttributes(
740
            attribute.Int("user.id", userID),
741
            attribute.String("date", date.Format("2006-01-02")),
742
        ),
743
    )
744
    defer func() {
745
        if err != nil {
746
            span.RecordError(err, trace.WithStackTrace(true))
747
            span.SetStatus(codes.Error, err.Error())
748
        }
749
        span.End()
750
    }()
751

752
    query := `
753
        SELECT COUNT(*)
754
        FROM daily_question_assignments
755
        WHERE user_id = $1 AND assignment_date = $2 AND is_completed = true
756
    `
757

758
    var count int
759
    err = s.db.QueryRowContext(ctx, query, userID, date).Scan(&count)
760
    if err != nil {
761
        return 0, contextutils.WrapError(err, "failed to get completed daily questions count")
762
    }
763

764
    return count, nil
765
}
766

767
// GetQuestionHistory retrieves the history of a specific question for a user over a given number of days
768
2x
func (s *DailyQuestionService) GetQuestionHistory(ctx context.Context, userID, questionID, days int) (result0 []*models.DailyQuestionHistory, err error) {
769
2x
    ctx, span := otel.Tracer("daily-question-service").Start(ctx, "GetQuestionHistory",
770
2x
        trace.WithAttributes(
771
2x
            attribute.Int("user.id", userID),
772
2x
            attribute.Int("question.id", questionID),
773
2x
            attribute.Int("days", days),
774
2x
        ),
775
2x
    )
776
2x
    defer func() {
777
2x
        if err != nil {
778
2x
            span.RecordError(err, trace.WithStackTrace(true))
779
2x
            span.SetStatus(codes.Error, err.Error())
780
2x
        }
781
2x
        span.End()
782
    }()
783

784
2x
    if days <= 0 {
785
2x
        return nil, contextutils.ErrorWithContextf("days must be positive")
786
2x
    }
787

788
    query := `
789
        SELECT dqa.assignment_date, dqa.is_completed, dqa.submitted_at,
790
               ur.is_correct
791
        FROM daily_question_assignments dqa
792
        LEFT JOIN daily_assignment_responses dar ON dar.assignment_id = dqa.id
793
        LEFT JOIN user_responses ur ON ur.id = dar.user_response_id
794
        WHERE dqa.user_id = $1 AND dqa.question_id = $2
795
        AND dqa.assignment_date >= NOW() - INTERVAL '` + fmt.Sprintf("%d days", days) + `'
796
        AND dqa.assignment_date <= CURRENT_DATE + INTERVAL '1 day'
797
        ORDER BY dqa.assignment_date ASC
798
    `
799

800
    rows, err := s.db.QueryContext(ctx, query, userID, questionID)
801
    if err != nil {
802
        span.RecordError(err)
803
        return nil, contextutils.WrapError(err, "failed to query question history")
804
    }
805
    defer func() {
806
        if closeErr := rows.Close(); closeErr != nil {
807
            s.logger.Error(ctx, "Failed to close rows", closeErr, map[string]interface{}{
808
                "user_id":     userID,
809
                "question_id": questionID,
810
                "days":        days,
811
            })
812
        }
813
    }()
814

815
    var history []*models.DailyQuestionHistory
816
    for rows.Next() {
817
        var historyEntry models.DailyQuestionHistory
818
        var isCorrect sql.NullBool
819
        err := rows.Scan(
820
            &historyEntry.AssignmentDate,
821
            &historyEntry.IsCompleted,
822
            &historyEntry.SubmittedAt,
823
            &isCorrect,
824
        )
825
        if err != nil {
826
            s.logger.Error(ctx, "Failed to scan question history entry", err, map[string]interface{}{
827
                "user_id":         userID,
828
                "question_id":     questionID,
829
                "assignment_date": historyEntry.AssignmentDate,
830
            })
831
            continue
832
        }
833
        if isCorrect.Valid {
834
            historyEntry.IsCorrect = &isCorrect.Bool
835
        } else {
836
            historyEntry.IsCorrect = nil
837
        }
838
        history = append(history, &historyEntry)
839
    }
840

841
    if err = rows.Err(); err != nil {
842
        span.RecordError(err)
843
        return nil, contextutils.WrapError(err, "error iterating over rows")
844
    }
845

846
    return history, nil
847
}
848

849
// getUserByID is a helper method to get user information
850
36x
func (s *DailyQuestionService) getUserByID(ctx context.Context, userID int) (*models.User, error) {
851
36x
    query := `
852
36x
        SELECT id, username, email, timezone, password_hash, last_active,
853
36x
               preferred_language, current_level, ai_provider, ai_model,
854
36x
               ai_enabled, ai_api_key, created_at, updated_at
855
36x
        FROM users
856
36x
        WHERE id = $1
857
36x
    `
858
36x

859
36x
    var user models.User
860
36x
    err := s.db.QueryRowContext(ctx, query, userID).Scan(
861
36x
        &user.ID, &user.Username, &user.Email, &user.Timezone, &user.PasswordHash,
862
36x
        &user.LastActive, &user.PreferredLanguage, &user.CurrentLevel, &user.AIProvider,
863
36x
        &user.AIModel, &user.AIEnabled, &user.AIAPIKey, &user.CreatedAt, &user.UpdatedAt,
864
36x
    )
865
36x
    if err != nil {
866
        if err == sql.ErrNoRows {
867
            return nil, nil
868
        }
869
        return nil, err
870
    }
871

872
36x
    return &user, nil
873
}
874

875
// SubmitDailyQuestionAnswer submits an answer for a daily question and marks it as completed
876
1x
func (s *DailyQuestionService) SubmitDailyQuestionAnswer(ctx context.Context, userID, questionID int, date time.Time, userAnswerIndex int) (result *api.AnswerResponse, err error) {
877
1x
    ctx, span := otel.Tracer("daily-question-service").Start(ctx, "SubmitDailyQuestionAnswer",
878
1x
        trace.WithAttributes(
879
1x
            attribute.Int("user.id", userID),
880
1x
            attribute.Int("question.id", questionID),
881
1x
            attribute.String("date", date.Format("2006-01-02")),
882
1x
            attribute.Int("user_answer_index", userAnswerIndex),
883
1x
        ),
884
1x
    )
885
1x
    defer func() {
886
1x
        if err != nil {
887
            span.RecordError(err, trace.WithStackTrace(true))
888
            span.SetStatus(codes.Error, err.Error())
889
        }
890
1x
        span.End()
891
    }()
892

893
1x
    s.logger.Info(ctx, "SubmitDailyQuestionAnswer started", map[string]interface{}{
894
1x
        "user_id":           userID,
895
1x
        "question_id":       questionID,
896
1x
        "date":              date.Format("2006-01-02"),
897
1x
        "user_answer_index": userAnswerIndex,
898
1x
    })
899
1x

900
1x
    // Check if the question is already answered
901
1x
    s.logger.Info(ctx, "Checking if question is already answered", map[string]interface{}{
902
1x
        "user_id":     userID,
903
1x
        "question_id": questionID,
904
1x
        "date":        date.Format("2006-01-02"),
905
1x
    })
906
1x

907
1x
    query := `
908
1x
        SELECT id, is_completed, user_answer_index, submitted_at
909
1x
        FROM daily_question_assignments
910
1x
        WHERE user_id = $1 AND question_id = $2 AND assignment_date = $3
911
1x
    `
912
1x

913
1x
    var assignmentID int
914
1x
    var isCompleted bool
915
1x
    var existingUserAnswerIndex *int
916
1x
    var existingSubmittedAt *time.Time
917
1x

918
1x
    err = s.db.QueryRowContext(ctx, query, userID, questionID, date).Scan(
919
1x
        &assignmentID, &isCompleted, &existingUserAnswerIndex, &existingSubmittedAt,
920
1x
    )
921
1x
    if err != nil {
922
        if err == sql.ErrNoRows {
923
            return nil, contextutils.ErrAssignmentNotFound
924
        }
925
        return nil, contextutils.WrapError(err, "failed to check question assignment")
926
    }
927

928
    // Check if already answered
929
1x
    if isCompleted && existingUserAnswerIndex != nil && existingSubmittedAt != nil {
930
        return nil, contextutils.ErrQuestionAlreadyAnswered
931
    }
932

933
    // Get the question details to validate answer and get correct answer
934
1x
    question, err := s.questionService.GetQuestionByID(ctx, questionID)
935
1x
    if err != nil {
936
        return nil, contextutils.WrapError(err, "failed to get question details")
937
    }
938

939
1x
    if question == nil {
940
        return nil, contextutils.ErrQuestionNotFound
941
    }
942

943
    // Extract options from content map
944
1x
    contentMap := question.Content
945
1x
    s.logger.Info(ctx, "Question content debug", map[string]interface{}{
946
1x
        "question_id": questionID,
947
1x
        "content_map": contentMap,
948
1x
    })
949
1x

950
1x
    optionsInterface, ok := contentMap["options"]
951
1x
    if !ok {
952
        s.logger.Error(ctx, "Question content missing options", nil, map[string]interface{}{
953
            "question_id": questionID,
954
            "content_map": contentMap,
955
        })
956
        return nil, contextutils.ErrorWithContextf("question content missing options")
957
    }
958

959
1x
    options, ok := optionsInterface.([]interface{})
960
1x
    if !ok {
961
        s.logger.Error(ctx, "Invalid options format", nil, map[string]interface{}{
962
            "question_id":       questionID,
963
            "options_interface": optionsInterface,
964
            "options_type":      fmt.Sprintf("%T", optionsInterface),
965
        })
966
        return nil, contextutils.ErrorWithContextf("invalid options format")
967
    }
968

969
    // Validate user answer index
970
1x
    if userAnswerIndex < 0 || userAnswerIndex >= len(options) {
971
        return nil, contextutils.ErrInvalidAnswerIndex
972
    }
973

974
    // Check if answer is correct
975
1x
    isCorrect := question.CorrectAnswer == userAnswerIndex
976
1x

977
1x
    // Begin transaction
978
1x
    tx, err := s.db.BeginTx(ctx, nil)
979
1x
    if err != nil {
980
        return nil, contextutils.WrapError(err, "failed to begin transaction")
981
    }
982

983
1x
    defer func() {
984
1x
        if err != nil {
985
            if rollbackErr := tx.Rollback(); rollbackErr != nil {
986
                s.logger.Error(ctx, "Failed to rollback transaction", rollbackErr, map[string]interface{}{
987
                    "error": rollbackErr.Error(),
988
                })
989
            }
990
        }
991
    }()
992

993
    // Update the assignment with the user's answer and mark as completed
994
1x
    updateQuery := `
995
1x
        UPDATE daily_question_assignments
996
1x
        SET is_completed = true, completed_at = NOW(), user_answer_index = $1, submitted_at = NOW()
997
1x
        WHERE id = $2
998
1x
    `
999
1x

1000
1x
    _, err = tx.ExecContext(ctx, updateQuery, userAnswerIndex, assignmentID)
1001
1x
    if err != nil {
1002
        return nil, contextutils.WrapError(err, "failed to update assignment")
1003
    }
1004

1005
    // Commit transaction
1006
1x
    err = tx.Commit()
1007
1x
    if err != nil {
1008
        return nil, contextutils.WrapError(err, "failed to commit transaction")
1009
    }
1010

1011
    // Record canonical user response via learningService so history queries see is_correct
1012
    // Use RecordAnswerWithPriorityReturningID to obtain user_responses.id so we can link it to the assignment.
1013
1x
    if s.learningService != nil {
1014
1x
        // record synchronously so we have the response id for mapping
1015
1x
        respID, recErr := s.learningService.RecordAnswerWithPriorityReturningID(ctx, userID, questionID, userAnswerIndex, isCorrect, 0)
1016
1x
        if recErr != nil {
1017
            s.logger.Error(ctx, "Failed to record user response for daily answer", recErr, map[string]interface{}{
1018
                "user_id":           userID,
1019
                "question_id":       questionID,
1020
                "user_answer_index": userAnswerIndex,
1021
            })
1022
        } else {
1023
1x
            // Insert mapping to daily_assignment_responses synchronously so tests that run immediately can observe it
1024
1x
            _, mapErr := s.db.ExecContext(ctx, `
1025
1x
                INSERT INTO daily_assignment_responses (assignment_id, user_response_id, created_at)
1026
1x
                VALUES ($1, $2, NOW())
1027
1x
                ON CONFLICT (assignment_id) DO UPDATE SET user_response_id = EXCLUDED.user_response_id, created_at = EXCLUDED.created_at
1028
1x
            `, assignmentID, respID)
1029
1x
            if mapErr != nil {
1030
                // Log but don't fail user's request
1031
                s.logger.Error(ctx, "Failed to insert daily_assignment_responses mapping", mapErr, map[string]interface{}{
1032
                    "assignment_id":    assignmentID,
1033
                    "user_response_id": respID,
1034
                })
1035
            }
1036

1037
            // If the answer was correct, remove future assignments for this question within the avoid window
1038
1x
            if isCorrect {
1039
1x
                // Determine avoidDays via questionService if possible; default to 7
1040
1x
                avoidDays := 7
1041
1x
                switch qs := s.questionService.(type) {
1042
1x
                case interface{ getDailyRepeatAvoidDays() int }:
1043
1x
                    avoidDays = qs.getDailyRepeatAvoidDays()
1044
                default:
1045
                    // leave default
1046
                }
1047

1048
1x
                startDate := date.AddDate(0, 0, 1)
1049
1x
                endDate := date.AddDate(0, 0, avoidDays)
1050
1x

1051
1x
                deleteQuery := `DELETE FROM daily_question_assignments WHERE user_id = $1 AND question_id = $2 AND assignment_date >= $3 AND assignment_date <= $4`
1052
1x
                if _, delErr := s.db.ExecContext(ctx, deleteQuery, userID, questionID, startDate, endDate); delErr != nil {
1053
                    s.logger.Error(ctx, "Failed to delete future daily assignments", delErr, map[string]interface{}{
1054
                        "user_id":     userID,
1055
                        "question_id": questionID,
1056
                        "start":       startDate,
1057
                        "end":         endDate,
1058
                    })
1059
                } else {
1060
1x
                    // Future assignments removed successfully; worker will top up missing slots on its next run
1061
1x
                    s.logger.Info(ctx, "Deleted future daily assignments for question; worker will refill dates as needed", map[string]interface{}{
1062
1x
                        "user_id":     userID,
1063
1x
                        "question_id": questionID,
1064
1x
                        "start":       startDate,
1065
1x
                        "end":         endDate,
1066
1x
                    })
1067
1x
                }
1068
            }
1069
        }
1070
    }
1071

1072
    // Build response
1073
1x
    userAnswer := options[userAnswerIndex].(string)
1074
1x
    response := &api.AnswerResponse{
1075
1x
        UserAnswerIndex: &userAnswerIndex,
1076
1x
        UserAnswer:      &userAnswer,
1077
1x
        IsCorrect:       &isCorrect,
1078
1x
    }
1079
1x

1080
1x
    // Add correct answer and explanation if available
1081
1x
    response.CorrectAnswerIndex = &question.CorrectAnswer
1082
1x
    if question.Explanation != "" {
1083
1x
        response.Explanation = &question.Explanation
1084
1x
    }
1085

1086
1x
    s.logger.Info(ctx, "Daily question answer submitted", map[string]interface{}{
1087
1x
        "user_id":           userID,
1088
1x
        "question_id":       questionID,
1089
1x
        "date":              date.Format("2006-01-02"),
1090
1x
        "user_answer_index": userAnswerIndex,
1091
1x
        "is_correct":        isCorrect,
1092
1x
    })
1093
1x

1094
1x
    return response, nil
1095
}
1096


			
quizapp internal services worker_service.go
90.9%
Statements
10/11
1
// Package services provides business logic services for the quiz application.
2
package services
3

4
import (
5
    "context"
6
    "database/sql"
7

8
    "quizapp/internal/config"
9
    "quizapp/internal/observability"
10
    "quizapp/internal/services/mailer"
11
)
12

13
// CreateEmailService creates an appropriate email service based on configuration
14
// If the application is running in test mode, it returns a TestEmailService
15
// Otherwise, it returns the regular EmailService
16
2x
func CreateEmailService(cfg *config.Config, logger *observability.Logger) mailer.Mailer {
17
2x
    if cfg.IsTest {
18
1x
        logger.Info(context.Background(), "Using test email service", map[string]interface{}{
19
1x
            "test_mode": true,
20
1x
        })
21
1x
        return NewTestEmailService(cfg, logger)
22
1x
    }
23

24
1x
    return NewEmailService(cfg, logger)
25
}
26

27
// CreateEmailServiceWithDB creates an appropriate email service with database connection based on configuration
28
// If the application is running in test mode, it returns a TestEmailService
29
// Otherwise, it returns the regular EmailService
30
2x
func CreateEmailServiceWithDB(cfg *config.Config, logger *observability.Logger, db *sql.DB) mailer.Mailer {
31
2x
    if cfg.IsTest {
32
1x
        logger.Info(context.Background(), "Using test email service with DB", map[string]interface{}{
33
1x
            "test_mode": true,
34
1x
        })
35
1x
        return NewTestEmailServiceWithDB(cfg, logger, db)
36
1x
    }
37

38
1x
    if db == nil {
39
1x
        logger.Error(context.Background(), "Database connection is nil, cannot create EmailService", nil, map[string]interface{}{
40
1x
            "error": "nil_database_connection",
41
1x
        })
42
1x
        panic("EmailService requires a non-nil database connection")
43
    }
44

45
    return NewEmailServiceWithDB(cfg, logger, db)
46
}
47


			
quizapp internal services worker_service.go
29.4%
Statements
35/119
1
// Package services provides business logic services for the quiz application.
2
package services
3

4
import (
5
    "context"
6
    "database/sql"
7
    "fmt"
8
    "html/template"
9
    "strings"
10
    "time"
11

12
    "quizapp/internal/config"
13
    "quizapp/internal/models"
14
    "quizapp/internal/observability"
15
    serviceinterfaces "quizapp/internal/serviceinterfaces"
16
    contextutils "quizapp/internal/utils"
17

18
    "go.opentelemetry.io/otel"
19
    "go.opentelemetry.io/otel/attribute"
20
    "go.opentelemetry.io/otel/trace"
21
    "gopkg.in/mail.v2"
22
)
23

24
// EmailService implements the interfaces.EmailService interface using gomail
25
type EmailService struct {
26
    cfg    *config.Config
27
    logger *observability.Logger
28
    dialer *mail.Dialer
29
    db     *sql.DB
30
}
31

32
// EmailServiceInterface defines the interface for email functionality
33
type EmailServiceInterface = serviceinterfaces.EmailService
34

35
// Ensure EmailService implements the EmailServiceInterface
36
var _ serviceinterfaces.EmailService = (*EmailService)(nil)
37

38
// NewEmailService creates a new EmailService instance
39
14x
func NewEmailService(cfg *config.Config, logger *observability.Logger) *EmailService {
40
14x
    var dialer *mail.Dialer
41
14x
    if cfg.Email.Enabled && cfg.Email.SMTP.Host != "" {
42
7x
        dialer = mail.NewDialer(
43
7x
            cfg.Email.SMTP.Host,
44
7x
            cfg.Email.SMTP.Port,
45
7x
            cfg.Email.SMTP.Username,
46
7x
            cfg.Email.SMTP.Password,
47
7x
        )
48
7x
    }
49

50
14x
    return &EmailService{
51
14x
        cfg:    cfg,
52
14x
        logger: logger,
53
14x
        dialer: dialer,
54
14x
    }
55
}
56

57
// NewEmailServiceWithDB creates a new EmailService instance with database connection
58
1x
func NewEmailServiceWithDB(cfg *config.Config, logger *observability.Logger, db *sql.DB) *EmailService {
59
1x
    if db == nil {
60
1x
        panic("EmailService requires a non-nil database connection")
61
    }
62

63
    var dialer *mail.Dialer
64
    if cfg.Email.Enabled && cfg.Email.SMTP.Host != "" {
65
        dialer = mail.NewDialer(
66
            cfg.Email.SMTP.Host,
67
            cfg.Email.SMTP.Port,
68
            cfg.Email.SMTP.Username,
69
            cfg.Email.SMTP.Password,
70
        )
71
    }
72

73
    return &EmailService{
74
        cfg:    cfg,
75
        logger: logger,
76
        dialer: dialer,
77
        db:     db,
78
    }
79
}
80

81
// SendDailyReminder sends a daily reminder email to a user
82
2x
func (e *EmailService) SendDailyReminder(ctx context.Context, user *models.User) (err error) {
83
2x
    ctx, span := otel.Tracer("email-service").Start(ctx, "SendDailyReminder",
84
2x
        trace.WithAttributes(
85
2x
            attribute.Int("user.id", user.ID),
86
2x
            attribute.String("user.email", user.Email.String),
87
2x
        ),
88
2x
    )
89
2x
    defer observability.FinishSpan(span, &err)
90
2x

91
2x
    if !e.IsEnabled() {
92
1x
        e.logger.Info(ctx, "Email disabled, skipping daily reminder", map[string]interface{}{
93
1x
            "user_id": user.ID,
94
1x
            "email":   user.Email.String,
95
1x
        })
96
1x
        return nil
97
1x
    }
98

99
1x
    if !user.Email.Valid || user.Email.String == "" {
100
1x
        e.logger.Warn(ctx, "User has no email address, skipping daily reminder", map[string]interface{}{
101
1x
            "user_id": user.ID,
102
1x
        })
103
1x
        return nil
104
1x
    }
105

106
    // Determine daily goal from DB
107
    dailyGoal := 10
108
    var dg sql.NullInt64
109
    if err := e.db.QueryRowContext(ctx, "SELECT daily_goal FROM user_learning_preferences WHERE user_id = $1", user.ID).Scan(&dg); err == nil && dg.Valid {
110
        dailyGoal = int(dg.Int64)
111
    }
112

113
    // Generate email data
114
    data := map[string]interface{}{
115
        "Username":       user.Username,
116
        "QuizAppURL":     e.cfg.Server.AppBaseURL, // Frontend app URL for email links
117
        "CurrentDate":    time.Now().Format("January 2, 2006"),
118
        "DailyGoal":      dailyGoal,
119
        "UnsubscribeURL": fmt.Sprintf("%s/settings", e.cfg.Server.AppBaseURL),
120
    }
121

122
    subject := "Time for your daily quiz! ð"
123

124
    err = e.SendEmail(ctx, user.Email.String, subject, "daily_reminder", data)
125
    if err != nil {
126
        return contextutils.WrapError(err, "failed to send daily reminder")
127
    }
128

129
    e.logger.Info(ctx, "Daily reminder sent successfully", map[string]interface{}{
130
        "user_id": user.ID,
131
        "email":   user.Email.String,
132
    })
133

134
    return nil
135
}
136

137
// SendEmail sends a generic email with the given parameters
138
2x
func (e *EmailService) SendEmail(ctx context.Context, to, subject, templateName string, data map[string]interface{}) (err error) {
139
2x
    ctx, span := otel.Tracer("email-service").Start(ctx, "SendEmail",
140
2x
        trace.WithAttributes(
141
2x
            attribute.String("email.to", to),
142
2x
            attribute.String("email.subject", subject),
143
2x
            attribute.String("email.template", templateName),
144
2x
        ),
145
2x
    )
146
2x
    defer observability.FinishSpan(span, &err)
147
2x

148
2x
    if !e.IsEnabled() {
149
2x
        e.logger.Info(ctx, "Email disabled, skipping email send", map[string]interface{}{
150
2x
            "to":       to,
151
2x
            "template": templateName,
152
2x
        })
153
2x
        return nil
154
2x
    }
155

156
    if e.dialer == nil {
157
        return contextutils.ErrorWithContextf("email service not properly configured")
158
    }
159

160
    // Create email message
161
    m := mail.NewMessage()
162
    m.SetHeader("From", fmt.Sprintf("%s <%s>", e.cfg.Email.SMTP.FromName, e.cfg.Email.SMTP.FromAddress))
163
    m.SetHeader("To", to)
164
    m.SetHeader("Subject", subject)
165

166
    // Generate email content from template
167
    content, err := e.generateEmailContent(templateName, data)
168
    if err != nil {
169
        return contextutils.WrapError(err, "failed to generate email content")
170
    }
171

172
    m.SetBody("text/html", content)
173

174
    // Send email
175
    if err = e.dialer.DialAndSend(m); err != nil {
176
        e.logger.Error(ctx, "Failed to send email", err, map[string]interface{}{
177
            "to":       to,
178
            "template": templateName,
179
            "subject":  subject,
180
        })
181
        return contextutils.WrapError(err, "failed to send email")
182
    }
183

184
    e.logger.Info(ctx, "Email sent successfully", map[string]interface{}{
185
        "to":       to,
186
        "template": templateName,
187
        "subject":  subject,
188
    })
189

190
    return nil
191
}
192

193
// RecordSentNotification records a sent notification in the database
194
func (e *EmailService) RecordSentNotification(ctx context.Context, userID int, notificationType, subject, templateName, status, errorMessage string) (err error) {
195
    ctx, span := otel.Tracer("email-service").Start(ctx, "RecordSentNotification",
196
        trace.WithAttributes(
197
            attribute.Int("user.id", userID),
198
            attribute.String("notification.type", notificationType),
199
            attribute.String("notification.status", status),
200
        ),
201
    )
202
    defer observability.FinishSpan(span, &err)
203

204
    if e.db == nil {
205
        e.logger.Error(ctx, "Database connection is nil, cannot record notification", nil, map[string]interface{}{
206
            "user_id":           userID,
207
            "notification_type": notificationType,
208
        })
209
        return contextutils.ErrorWithContextf("EmailService database connection is nil")
210
    }
211

212
    query := `
213
        INSERT INTO sent_notifications (user_id, notification_type, subject, template_name, sent_at, status, error_message)
214
        VALUES ($1, $2, $3, $4, $5, $6, $7)
215
    `
216

217
    _, err = e.db.ExecContext(ctx, query, userID, notificationType, subject, templateName, time.Now(), status, errorMessage)
218
    if err != nil {
219
        e.logger.Error(ctx, "Failed to record sent notification", err, map[string]interface{}{
220
            "user_id":           userID,
221
            "notification_type": notificationType,
222
            "status":            status,
223
        })
224
        return contextutils.WrapError(err, "failed to record sent notification")
225
    }
226

227
    e.logger.Info(ctx, "Recorded sent notification", map[string]interface{}{
228
        "user_id":           userID,
229
        "notification_type": notificationType,
230
        "status":            status,
231
    })
232

233
    return nil
234
}
235

236
// IsEnabled returns whether email functionality is enabled
237
9x
func (e *EmailService) IsEnabled() bool {
238
9x
    return e.cfg.Email.Enabled && e.cfg.Email.SMTP.Host != ""
239
9x
}
240

241
// generateEmailContent generates email content from templates
242
2x
func (e *EmailService) generateEmailContent(templateName string, data map[string]interface{}) (string, error) {
243
2x
    // For now, we'll use a simple template system
244
2x
    // In a real implementation, you might load templates from files or database
245
2x
    switch templateName {
246
1x
    case "daily_reminder":
247
1x
        return e.generateDailyReminderTemplate(data)
248
    case "test_email":
249
        return e.generateTestEmailTemplate(data)
250
    case "word_of_the_day":
251
        return e.generateWordOfTheDayTemplate(data)
252
1x
    default:
253
1x
        return "", contextutils.ErrorWithContextf("unknown template: %s", templateName)
254
    }
255
}
256

257
// generateDailyReminderTemplate generates the daily reminder email template
258
2x
func (e *EmailService) generateDailyReminderTemplate(data map[string]interface{}) (string, error) {
259
2x
    const templateStr = `
260
2x
<!DOCTYPE html>
261
2x
<html>
262
2x
<head>
263
2x
    <meta charset="UTF-8">
264
2x
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
265
2x
    <title>Daily Quiz Reminder</title>
266
2x
    <style>
267
2x
        body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; }
268
2x
        .container { max-width: 600px; margin: 0 auto; padding: 20px; }
269
2x
        .header { background-color: #4CAF50; color: white; padding: 20px; text-align: center; border-radius: 5px 5px 0 0; }
270
2x
        .content { background-color: #f9f9f9; padding: 20px; }
271
2x
        .button { display: inline-block; background-color: #4CAF50; color: white; padding: 12px 24px; text-decoration: none; border-radius: 5px; margin: 20px 0; }
272
2x
        .footer { background-color: #eee; padding: 15px; text-align: center; font-size: 12px; color: #666; border-radius: 0 0 5px 5px; }
273
2x
    </style>
274
2x
</head>
275
2x
<body>
276
2x
    <div class="container">
277
2x
        <div class="header">
278
2x
            <h1>ð Daily Quiz Reminder</h1>
279
2x
        </div>
280
2x
        <div class="content">
281
2x
            <h2>Hello {{.Username}}!</h2>
282
2x
            <p>It's {{.CurrentDate}} and time for your daily questions!</p>
283
2x
            <p>Your goal today: <strong>{{.DailyGoal}} questions</strong></p>
284
2x
            <p>Keep up the great work and continue improving your language skills!</p>
285
2x
            <div style="text-align: center;">
286
2x
                <a href="{{.QuizAppURL}}/daily" class="button">Start Your Daily Questions</a>
287
2x
            </div>
288
2x
        </div>
289
2x
        <div class="footer">
290
2x
            <p>This email was sent by Quiz App. If you no longer wish to receive these reminders, you can <a href="{{.UnsubscribeURL}}">unsubscribe here</a>.</p>
291
2x
        </div>
292
2x
    </div>
293
2x
</body>
294
2x
</html>`
295
2x

296
2x
    tmpl, err := template.New("daily_reminder").Parse(templateStr)
297
2x
    if err != nil {
298
        return "", contextutils.WrapError(err, "failed to parse template")
299
    }
300

301
2x
    var buf strings.Builder
302
2x
    if err := tmpl.Execute(&buf, data); err != nil {
303
        return "", contextutils.WrapError(err, "failed to execute template")
304
    }
305

306
2x
    return buf.String(), nil
307
}
308

309
// generateTestEmailTemplate generates the test email template
310
2x
func (e *EmailService) generateTestEmailTemplate(data map[string]interface{}) (string, error) {
311
2x
    const templateStr = `
312
2x
<!DOCTYPE html>
313
2x
<html>
314
2x
<head>
315
2x
    <meta charset="UTF-8">
316
2x
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
317
2x
    <title>Test Email</title>
318
2x
    <style>
319
2x
        body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; }
320
2x
        .container { max-width: 600px; margin: 0 auto; padding: 20px; }
321
2x
        .header { background-color: #2196F3; color: white; padding: 20px; text-align: center; border-radius: 5px 5px 0 0; }
322
2x
        .content { background-color: #f9f9f9; padding: 20px; }
323
2x
        .footer { background-color: #eee; padding: 15px; text-align: center; font-size: 12px; color: #666; border-radius: 0 0 5px 5px; }
324
2x
    </style>
325
2x
</head>
326
2x
<body>
327
2x
    <div class="container">
328
2x
        <div class="header">
329
2x
            <h1>ð Test Email</h1>
330
2x
        </div>
331
2x
        <div class="content">
332
2x
            <h2>Hello {{.Username}}!</h2>
333
2x
            <p>This is a test email to verify that your email settings are working correctly.</p>
334
2x
            <p><strong>Test Time:</strong> {{.TestTime}}</p>
335
2x
            <p><strong>Message:</strong> {{.Message}}</p>
336
2x
            <p>If you received this email, your email configuration is working properly!</p>
337
2x
        </div>
338
2x
        <div class="footer">
339
2x
            <p>This is a test email from Quiz App. No action is required.</p>
340
2x
        </div>
341
2x
    </div>
342
2x
</body>
343
2x
</html>
344
2x
`
345
2x

346
2x
    tmpl, err := template.New("test_email").Parse(templateStr)
347
2x
    if err != nil {
348
        return "", contextutils.WrapError(err, "failed to parse template")
349
    }
350

351
2x
    var buf strings.Builder
352
2x
    if err := tmpl.Execute(&buf, data); err != nil {
353
        return "", contextutils.WrapError(err, "failed to execute template")
354
    }
355

356
2x
    return buf.String(), nil
357
}
358

359
// SendWordOfTheDayEmail sends a word of the day email to a user
360
func (e *EmailService) SendWordOfTheDayEmail(ctx context.Context, userID int, date time.Time, wordOfTheDay *models.WordOfTheDayDisplay) (err error) {
361
    ctx, span := otel.Tracer("email-service").Start(ctx, "SendWordOfTheDayEmail",
362
        trace.WithAttributes(
363
            attribute.Int("email.user_id", userID),
364
            attribute.String("email.date", date.Format("2006-01-02")),
365
        ),
366
    )
367
    defer observability.FinishSpan(span, &err)
368

369
    if !e.IsEnabled() {
370
        e.logger.Info(ctx, "Email disabled, skipping word of the day email", map[string]interface{}{
371
            "user_id": userID,
372
            "date":    date.Format("2006-01-02"),
373
        })
374
        return nil
375
    }
376

377
    // Get user to check email preferences
378
    user, err := e.getUserByID(ctx, userID)
379
    if err != nil {
380
        return contextutils.WrapError(err, "failed to get user")
381
    }
382

383
    if user == nil {
384
        return contextutils.ErrorWithContextf("user not found: %d", userID)
385
    }
386

387
    // Check if user has email disabled for word of the day
388
    if !user.WordOfDayEmailEnabled.Bool {
389
        e.logger.Info(ctx, "User has word of the day emails disabled", map[string]interface{}{
390
            "user_id": userID,
391
        })
392
        return nil
393
    }
394

395
    if !user.Email.Valid || user.Email.String == "" {
396
        return contextutils.ErrorWithContextf("user has no email address")
397
    }
398

399
    // Prepare email data
400
    data := map[string]interface{}{
401
        "Username":       user.Username,
402
        "Word":           wordOfTheDay.Word,
403
        "Translation":    wordOfTheDay.Translation,
404
        "Sentence":       wordOfTheDay.Sentence,
405
        "Date":           date.Format("January 2, 2006"),
406
        "Language":       wordOfTheDay.Language,
407
        "Level":          wordOfTheDay.Level,
408
        "Explanation":    wordOfTheDay.Explanation,
409
        "QuizAppURL":     e.cfg.Server.AppBaseURL,
410
        "UnsubscribeURL": fmt.Sprintf("%s/settings?tab=notifications", e.cfg.Server.AppBaseURL),
411
    }
412

413
    subject := fmt.Sprintf("Word of the Day: %s - %s", wordOfTheDay.Word, date.Format("January 2, 2006"))
414

415
    return e.SendEmail(ctx, user.Email.String, subject, "word_of_the_day", data)
416
}
417

418
// generateWordOfTheDayTemplate generates the word of the day email template
419
func (e *EmailService) generateWordOfTheDayTemplate(data map[string]interface{}) (string, error) {
420
    const templateStr = `
421
<!DOCTYPE html>
422
<html>
423
<head>
424
    <meta charset="UTF-8">
425
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
426
    <title>Word of the Day</title>
427
    <style>
428
        body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; line-height: 1.6; color: #333; margin: 0; padding: 0; }
429
        .container { max-width: 600px; margin: 0 auto; padding: 20px; }
430
        .header { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 30px 20px; text-align: center; border-radius: 8px 8px 0 0; }
431
        .content { background-color: #ffffff; padding: 30px; border: 1px solid #e0e0e0; border-top: none; }
432
        .date { color: #667eea; font-size: 14px; font-weight: 600; text-transform: uppercase; letter-spacing: 1px; margin-bottom: 15px; }
433
        .word { font-size: 48px; font-weight: bold; color: #1a1a1a; margin-bottom: 15px; line-height: 1.2; }
434
        .translation { font-size: 24px; color: #667eea; margin-bottom: 25px; font-style: italic; }
435
        .sentence { font-size: 18px; line-height: 1.8; color: #555; background: #f7f7f7; padding: 25px; border-radius: 8px; border-left: 4px solid #667eea; margin-bottom: 20px; font-style: italic; }
436
        .explanation { font-size: 15px; color: #666; margin-top: 20px; padding: 20px; background: #fafafa; border-radius: 8px; border-left: 3px solid #764ba2; }
437
        .meta { display: flex; gap: 10px; flex-wrap: wrap; margin-top: 20px; }
438
        .badge { background: #e0e7ff; color: #667eea; padding: 6px 12px; border-radius: 20px; font-size: 12px; font-weight: 600; }
439
        .button { display: inline-block; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 14px 28px; text-decoration: none; border-radius: 6px; margin: 20px 0; font-weight: 600; }
440
        .footer { background-color: #f5f5f5; padding: 20px; text-align: center; font-size: 12px; color: #666; border-radius: 0 0 8px 8px; border: 1px solid #e0e0e0; border-top: none; }
441
        .footer a { color: #667eea; text-decoration: none; }
442
    </style>
443
</head>
444
<body>
445
    <div class="container">
446
        <div class="header">
447
            <h1 style="margin: 0; font-size: 28px;">ð Word of the Day</h1>
448
        </div>
449
        <div class="content">
450
            <div class="date">{{.Date}}</div>
451
            <div class="word">{{.Word}}</div>
452
            <div class="translation">{{.Translation}}</div>
453
            {{if .Sentence}}
454
            <div class="sentence">{{.Sentence}}</div>
455
            {{end}}
456
            {{if .Explanation}}
457
            <div class="explanation">{{.Explanation}}</div>
458
            {{end}}
459
            <div class="meta">
460
                {{if .Language}}<span class="badge">{{.Language}}</span>{{end}}
461
                {{if .Level}}<span class="badge">{{.Level}}</span>{{end}}
462
            </div>
463
            <div style="text-align: center; margin-top: 30px;">
464
                <a href="{{.QuizAppURL}}/word-of-day" class="button">View in App</a>
465
            </div>
466
        </div>
467
        <div class="footer">
468
            <p>This email was sent by Quiz App. If you no longer wish to receive word of the day emails, you can <a href="{{.UnsubscribeURL}}">update your preferences here</a>.</p>
469
        </div>
470
    </div>
471
</body>
472
</html>`
473

474
    tmpl, err := template.New("word_of_the_day").Parse(templateStr)
475
    if err != nil {
476
        return "", contextutils.WrapError(err, "failed to parse template")
477
    }
478

479
    var buf strings.Builder
480
    if err := tmpl.Execute(&buf, data); err != nil {
481
        return "", contextutils.WrapError(err, "failed to execute template")
482
    }
483

484
    return buf.String(), nil
485
}
486

487
// getUserByID retrieves a user by ID (helper method)
488
func (e *EmailService) getUserByID(ctx context.Context, userID int) (*models.User, error) {
489
    if e.db == nil {
490
        return nil, contextutils.ErrorWithContextf("database connection not available")
491
    }
492

493
    query := `
494
        SELECT id, username, email, word_of_day_email_enabled
495
        FROM users
496
        WHERE id = $1
497
    `
498

499
    var user models.User
500
    err := e.db.QueryRowContext(ctx, query, userID).Scan(
501
        &user.ID,
502
        &user.Username,
503
        &user.Email,
504
        &user.WordOfDayEmailEnabled,
505
    )
506

507
    if err == sql.ErrNoRows {
508
        return nil, nil
509
    }
510

511
    if err != nil {
512
        return nil, contextutils.WrapError(err, "failed to query user")
513
    }
514

515
    return &user, nil
516
}
517


			
quizapp internal services worker_service.go
0.0%
Statements
0/126
1
package services
2

3
import (
4
    "context"
5
    "database/sql"
6
    "encoding/json"
7
    "fmt"
8
    "strings"
9
    "time"
10

11
    "quizapp/internal/models"
12
    "quizapp/internal/observability"
13
    contextutils "quizapp/internal/utils"
14

15
    "go.opentelemetry.io/otel/attribute"
16
)
17

18
// FeedbackService implements FeedbackServiceInterface for managing feedback reports.
19
type FeedbackService struct {
20
    db     *sql.DB
21
    logger *observability.Logger
22
}
23

24
// NewFeedbackService creates a new FeedbackService instance.
25
func NewFeedbackService(db *sql.DB, logger *observability.Logger) *FeedbackService {
26
    if db == nil {
27
        panic("NewFeedbackService: db is nil")
28
    }
29
    if logger == nil {
30
        panic("NewFeedbackService: logger is nil")
31
    }
32
    return &FeedbackService{db: db, logger: logger}
33
}
34

35
// CreateFeedback inserts a new feedback report.
36
func (s *FeedbackService) CreateFeedback(ctx context.Context, fr *models.FeedbackReport) (result0 *models.FeedbackReport, err error) {
37
    ctx, span := observability.TraceUserFunction(ctx, "create_feedback")
38
    defer observability.FinishSpan(span, &err)
39

40
    contextJSON, err := json.Marshal(fr.ContextData)
41
    if err != nil {
42
        return nil, contextutils.WrapError(err, "failed to marshal context_data")
43
    }
44

45
    query := `INSERT INTO feedback_reports (user_id, feedback_text, feedback_type, context_data, screenshot_data, screenshot_url, status, created_at, updated_at)
46
              VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) RETURNING id, created_at, updated_at`
47
    now := time.Now()
48
    var id int
49
    var createdAt, updatedAt time.Time
50
    err = s.db.QueryRowContext(ctx, query, fr.UserID, fr.FeedbackText, fr.FeedbackType, contextJSON, fr.ScreenshotData, fr.ScreenshotURL, "new", now, now).
51
        Scan(&id, &createdAt, &updatedAt)
52
    if err != nil {
53
        return nil, contextutils.WrapError(err, "failed to insert feedback report")
54
    }
55
    fr.ID = id
56
    fr.Status = "new"
57
    fr.CreatedAt = createdAt
58
    fr.UpdatedAt = updatedAt
59
    return fr, nil
60
}
61

62
// GetFeedbackByID fetches single feedback.
63
func (s *FeedbackService) GetFeedbackByID(ctx context.Context, id int) (result0 *models.FeedbackReport, err error) {
64
    ctx, span := observability.TraceUserFunction(ctx, "get_feedback_by_id")
65
    defer observability.FinishSpan(span, &err)
66

67
    query := `SELECT id, user_id, feedback_text, feedback_type, context_data, screenshot_data, screenshot_url, status, admin_notes, assigned_to_user_id, resolved_at, resolved_by_user_id, created_at, updated_at FROM feedback_reports WHERE id=$1`
68
    row := s.db.QueryRowContext(ctx, query, id)
69
    var fr models.FeedbackReport
70
    var contextJSON []byte
71
    err = row.Scan(&fr.ID, &fr.UserID, &fr.FeedbackText, &fr.FeedbackType, &contextJSON, &fr.ScreenshotData, &fr.ScreenshotURL, &fr.Status, &fr.AdminNotes, &fr.AssignedToUserID, &fr.ResolvedAt, &fr.ResolvedByUserID, &fr.CreatedAt, &fr.UpdatedAt)
72
    if err != nil {
73
        if err == sql.ErrNoRows {
74
            return nil, contextutils.ErrRecordNotFound
75
        }
76
        return nil, contextutils.WrapError(err, "failed to scan feedback")
77
    }
78
    _ = json.Unmarshal(contextJSON, &fr.ContextData)
79
    return &fr, nil
80
}
81

82
// GetFeedbackPaginated returns list of feedback reports with filters.
83
func (s *FeedbackService) GetFeedbackPaginated(ctx context.Context, page, pageSize int, status, feedbackType string, userID *int) (result0 []models.FeedbackReport, result1 int, err error) {
84
    ctx, span := observability.TraceUserFunction(ctx, "get_feedback_paginated")
85
    defer observability.FinishSpan(span, &err)
86

87
    var conditions []string
88
    var args []interface{}
89
    idx := 1
90
    if status != "" {
91
        conditions = append(conditions, fmt.Sprintf("status=$%d", idx))
92
        args = append(args, status)
93
        idx++
94
    }
95
    if feedbackType != "" {
96
        conditions = append(conditions, fmt.Sprintf("feedback_type=$%d", idx))
97
        args = append(args, feedbackType)
98
        idx++
99
    }
100
    if userID != nil {
101
        conditions = append(conditions, fmt.Sprintf("user_id=$%d", idx))
102
        args = append(args, *userID)
103
        idx++
104
    }
105
    where := ""
106
    if len(conditions) > 0 {
107
        where = "WHERE " + strings.Join(conditions, " AND ")
108
    }
109

110
    countQuery := fmt.Sprintf("SELECT COUNT(*) FROM feedback_reports %s", where)
111
    var total int
112
    if err = s.db.QueryRowContext(ctx, countQuery, args...).Scan(&total); err != nil {
113
        return nil, 0, contextutils.WrapError(err, "failed to count feedback")
114
    }
115

116
    offset := (page - 1) * pageSize
117
    args = append(args, pageSize, offset)
118
    query := fmt.Sprintf("SELECT id, user_id, feedback_text, feedback_type, context_data, screenshot_data, screenshot_url, status, admin_notes, assigned_to_user_id, resolved_at, resolved_by_user_id, created_at, updated_at FROM feedback_reports %s ORDER BY created_at DESC LIMIT $%d OFFSET $%d", where, idx, idx+1)
119

120
    rows, err := s.db.QueryContext(ctx, query, args...)
121
    if err != nil {
122
        return nil, 0, contextutils.WrapError(err, "failed to query feedback list")
123
    }
124
    defer func() {
125
        _ = rows.Close()
126
    }()
127

128
    list := []models.FeedbackReport{}
129
    for rows.Next() {
130
        var fr models.FeedbackReport
131
        var contextJSON []byte
132
        if err := rows.Scan(&fr.ID, &fr.UserID, &fr.FeedbackText, &fr.FeedbackType, &contextJSON, &fr.ScreenshotData, &fr.ScreenshotURL, &fr.Status, &fr.AdminNotes, &fr.AssignedToUserID, &fr.ResolvedAt, &fr.ResolvedByUserID, &fr.CreatedAt, &fr.UpdatedAt); err != nil {
133
            return nil, 0, contextutils.WrapError(err, "scan feedback list")
134
        }
135
        _ = json.Unmarshal(contextJSON, &fr.ContextData)
136
        list = append(list, fr)
137
    }
138
    return list, total, nil
139
}
140

141
// UpdateFeedback allows status/notes assignment updates.
142
func (s *FeedbackService) UpdateFeedback(ctx context.Context, id int, updates map[string]interface{}) (result0 *models.FeedbackReport, err error) {
143
    ctx, span := observability.TraceUserFunction(ctx, "update_feedback", attribute.Int("feedback.id", id))
144
    defer observability.FinishSpan(span, &err)
145

146
    if len(updates) == 0 {
147
        return s.GetFeedbackByID(ctx, id)
148
    }
149

150
    var sets []string
151
    var args []interface{}
152
    idx := 1
153
    for k, v := range updates {
154
        sets = append(sets, fmt.Sprintf("%s=$%d", k, idx))
155
        args = append(args, v)
156
        idx++
157
    }
158
    sets = append(sets, fmt.Sprintf("updated_at=$%d", idx))
159
    args = append(args, time.Now())
160
    args = append(args, id)
161

162
    query := fmt.Sprintf("UPDATE feedback_reports SET %s WHERE id=$%d", strings.Join(sets, ","), idx+1)
163
    if _, err := s.db.ExecContext(ctx, query, args...); err != nil {
164
        return nil, contextutils.WrapError(err, "failed to update feedback")
165
    }
166
    return s.GetFeedbackByID(ctx, id)
167
}
168

169
// DeleteFeedback deletes a single feedback report by ID.
170
func (s *FeedbackService) DeleteFeedback(ctx context.Context, id int) (err error) {
171
    ctx, span := observability.TraceUserFunction(ctx, "delete_feedback", attribute.Int("feedback.id", id))
172
    defer observability.FinishSpan(span, &err)
173

174
    query := `DELETE FROM feedback_reports WHERE id=$1`
175
    result, err := s.db.ExecContext(ctx, query, id)
176
    if err != nil {
177
        return contextutils.WrapError(err, "failed to delete feedback")
178
    }
179

180
    rowsAffected, err := result.RowsAffected()
181
    if err != nil {
182
        return contextutils.WrapError(err, "failed to get rows affected")
183
    }
184

185
    if rowsAffected == 0 {
186
        return contextutils.WrapErrorf(contextutils.ErrRecordNotFound, "feedback with ID %d not found", id)
187
    }
188

189
    return nil
190
}
191

192
// DeleteFeedbackByStatus deletes all feedback reports with a specific status.
193
func (s *FeedbackService) DeleteFeedbackByStatus(ctx context.Context, status string) (result0 int, err error) {
194
    ctx, span := observability.TraceUserFunction(ctx, "delete_feedback_by_status", attribute.String("status", status))
195
    defer observability.FinishSpan(span, &err)
196

197
    query := `DELETE FROM feedback_reports WHERE status=$1`
198
    result, err := s.db.ExecContext(ctx, query, status)
199
    if err != nil {
200
        return 0, contextutils.WrapError(err, "failed to delete feedback by status")
201
    }
202

203
    rowsAffected, err := result.RowsAffected()
204
    if err != nil {
205
        return 0, contextutils.WrapError(err, "failed to get rows affected")
206
    }
207

208
    return int(rowsAffected), nil
209
}
210

211
// DeleteAllFeedback deletes all feedback reports regardless of status.
212
func (s *FeedbackService) DeleteAllFeedback(ctx context.Context) (result0 int, err error) {
213
    ctx, span := observability.TraceUserFunction(ctx, "delete_all_feedback")
214
    defer observability.FinishSpan(span, &err)
215

216
    query := `DELETE FROM feedback_reports`
217
    result, err := s.db.ExecContext(ctx, query)
218
    if err != nil {
219
        return 0, contextutils.WrapError(err, "failed to delete all feedback")
220
    }
221

222
    rowsAffected, err := result.RowsAffected()
223
    if err != nil {
224
        return 0, contextutils.WrapError(err, "failed to get rows affected")
225
    }
226

227
    return int(rowsAffected), nil
228
}
229


			
quizapp internal services worker_service.go
83.3%
Statements
25/30
1
package services
2

3
import (
4
    "context"
5
    "database/sql"
6
    "time"
7

8
    "quizapp/internal/models"
9
    "quizapp/internal/observability"
10
    contextutils "quizapp/internal/utils"
11
)
12

13
// GenerationHint represents an active generation hint
14
type GenerationHint struct {
15
    ID             int       `db:"id"`
16
    UserID         int       `db:"user_id"`
17
    Language       string    `db:"language"`
18
    Level          string    `db:"level"`
19
    QuestionType   string    `db:"question_type"`
20
    PriorityWeight int       `db:"priority_weight"`
21
    ExpiresAt      time.Time `db:"expires_at"`
22
    CreatedAt      time.Time `db:"created_at"`
23
}
24

25
// GenerationHintServiceInterface defines the API for managing generation hints
26
type GenerationHintServiceInterface interface {
27
    UpsertHint(ctx context.Context, userID int, language, level string, qType models.QuestionType, ttl time.Duration) error
28
    GetActiveHintsForUser(ctx context.Context, userID int) ([]GenerationHint, error)
29
    ClearHint(ctx context.Context, userID int, language, level string, qType models.QuestionType) error
30
}
31

32
// GenerationHintService implements hint management
33
type GenerationHintService struct {
34
    db     *sql.DB
35
    logger *observability.Logger
36
}
37

38
// NewGenerationHintService constructs a service for managing short-lived per-user
39
// generation hints that nudge the worker to prioritize specific question types
40
// (e.g., reading comprehension) when the user is waiting for generation.
41
1x
func NewGenerationHintService(db *sql.DB, logger *observability.Logger) *GenerationHintService {
42
1x
    return &GenerationHintService{db: db, logger: logger}
43
1x
}
44

45
// UpsertHint creates or refreshes a hint with the given TTL
46
1x
func (s *GenerationHintService) UpsertHint(ctx context.Context, userID int, language, level string, qType models.QuestionType, ttl time.Duration) (err error) {
47
1x
    ctx, span := observability.TraceWorkerFunction(ctx, "upsert_generation_hint")
48
1x
    defer observability.FinishSpan(span, &err)
49
1x

50
1x
    expiresAt := time.Now().Add(ttl)
51
1x
    _, err = s.db.ExecContext(ctx, `
52
1x
        INSERT INTO generation_hints (user_id, language, level, question_type, priority_weight, expires_at)
53
1x
        VALUES ($1, $2, $3, $4, 1, $5)
54
1x
        ON CONFLICT (user_id, language, level, question_type) DO UPDATE SET
55
1x
            priority_weight = generation_hints.priority_weight + 1,
56
1x
            expires_at = EXCLUDED.expires_at,
57
1x
            created_at = generation_hints.created_at
58
1x
    `, userID, language, level, string(qType), expiresAt)
59
1x
    if err != nil {
60
        return contextutils.WrapError(err, "failed to upsert generation hint")
61
    }
62
1x
    return nil
63
}
64

65
// GetActiveHintsForUser returns non-expired hints for the user
66
2x
func (s *GenerationHintService) GetActiveHintsForUser(ctx context.Context, userID int) (result0 []GenerationHint, err error) {
67
2x
    ctx, span := observability.TraceWorkerFunction(ctx, "get_active_generation_hints")
68
2x
    defer observability.FinishSpan(span, &err)
69
2x

70
2x
    rows, err := s.db.QueryContext(ctx, `
71
2x
        SELECT id, user_id, language, level, question_type, priority_weight, expires_at, created_at
72
2x
        FROM generation_hints
73
2x
        WHERE user_id = $1 AND expires_at > NOW()
74
2x
        ORDER BY created_at ASC
75
2x
    `, userID)
76
2x
    if err != nil {
77
        return nil, contextutils.WrapError(err, "failed to query generation hints")
78
    }
79
2x
    defer func() { _ = rows.Close() }()
80

81
2x
    var hints []GenerationHint
82
2x
    for rows.Next() {
83
1x
        var h GenerationHint
84
1x
        if err := rows.Scan(&h.ID, &h.UserID, &h.Language, &h.Level, &h.QuestionType, &h.PriorityWeight, &h.ExpiresAt, &h.CreatedAt); err != nil {
85
            return nil, contextutils.WrapError(err, "failed to scan generation hint")
86
        }
87
1x
        hints = append(hints, h)
88
    }
89
2x
    if err := rows.Err(); err != nil {
90
        return nil, contextutils.WrapError(err, "error iterating generation hints")
91
    }
92
2x
    return hints, nil
93
}
94

95
// ClearHint deletes a specific hint
96
1x
func (s *GenerationHintService) ClearHint(ctx context.Context, userID int, language, level string, qType models.QuestionType) (err error) {
97
1x
    ctx, span := observability.TraceWorkerFunction(ctx, "clear_generation_hint")
98
1x
    defer observability.FinishSpan(span, &err)
99
1x

100
1x
    _, err = s.db.ExecContext(ctx, `
101
1x
        DELETE FROM generation_hints
102
1x
        WHERE user_id = $1 AND language = $2 AND level = $3 AND question_type = $4
103
1x
    `, userID, language, level, string(qType))
104
1x
    if err != nil {
105
        return contextutils.WrapError(err, "failed to clear generation hint")
106
    }
107
1x
    return nil
108
}
109


			
quizapp internal services worker_service.go
75.7%
Statements
577/762
1
package services
2

3
import (
4
    "context"
5
    "database/sql"
6
    "fmt"
7
    "math"
8
    "strings"
9
    "time"
10

11
    "quizapp/internal/config"
12
    "quizapp/internal/models"
13
    "quizapp/internal/observability"
14
    contextutils "quizapp/internal/utils"
15

16
    "github.com/lib/pq"
17
    "go.opentelemetry.io/otel/attribute"
18
    "go.opentelemetry.io/otel/codes"
19
    "go.opentelemetry.io/otel/trace"
20
)
21

22
// LearningServiceInterface defines the interface for the learning service
23
type LearningServiceInterface interface {
24
    RecordUserResponse(ctx context.Context, response *models.UserResponse) error
25
    GetUserProgress(ctx context.Context, userID int) (*models.UserProgress, error)
26
    GetWeakestTopics(ctx context.Context, userID, limit int) ([]*models.PerformanceMetrics, error)
27
    ShouldAvoidQuestion(ctx context.Context, userID, questionID int) (bool, error)
28
    GetUserQuestionStats(ctx context.Context, userID int) (*UserQuestionStats, error)
29
    // Priority system methods
30
    RecordAnswerWithPriority(ctx context.Context, userID, questionID, answerIndex int, isCorrect bool, responseTime int) error
31
    // RecordAnswerWithPriorityReturningID records the response and returns the created user_responses.id
32
    RecordAnswerWithPriorityReturningID(ctx context.Context, userID, questionID, answerIndex int, isCorrect bool, responseTime int) (int, error)
33
    MarkQuestionAsKnown(ctx context.Context, userID, questionID int, confidenceLevel *int) error
34
    GetUserLearningPreferences(ctx context.Context, userID int) (*models.UserLearningPreferences, error)
35
    UpdateLastDailyReminderSent(ctx context.Context, userID int) error
36
    CalculatePriorityScore(ctx context.Context, userID, questionID int) (float64, error)
37
    UpdateUserLearningPreferences(ctx context.Context, userID int, prefs *models.UserLearningPreferences) (*models.UserLearningPreferences, error)
38
    GetUserQuestionConfidenceLevel(ctx context.Context, userID, questionID int) (*int, error)
39
    // Analytics methods
40
    GetPriorityScoreDistribution(ctx context.Context) (map[string]interface{}, error)
41
    GetHighPriorityQuestions(ctx context.Context, limit int) ([]map[string]interface{}, error)
42
    GetWeakAreasByTopic(ctx context.Context, limit int) ([]map[string]interface{}, error)
43
    GetLearningPreferencesUsage(ctx context.Context) (map[string]interface{}, error)
44
    GetQuestionTypeGaps(ctx context.Context) ([]map[string]interface{}, error)
45
    GetGenerationSuggestions(ctx context.Context) ([]map[string]interface{}, error)
46
    GetPrioritySystemPerformance(ctx context.Context) (map[string]interface{}, error)
47
    GetBackgroundJobsStatus(ctx context.Context) (map[string]interface{}, error)
48
    // User-specific analytics methods
49
    GetUserPriorityScoreDistribution(ctx context.Context, userID int) (map[string]interface{}, error)
50
    GetUserHighPriorityQuestions(ctx context.Context, userID, limit int) ([]map[string]interface{}, error)
51
    GetUserWeakAreas(ctx context.Context, userID, limit int) ([]map[string]interface{}, error)
52
    // Additional analytics methods for progress API
53
    GetHighPriorityTopics(ctx context.Context, userID int) ([]string, error)
54
    GetGapAnalysis(ctx context.Context, userID int) (map[string]interface{}, error)
55
    GetPriorityDistribution(ctx context.Context, userID int) (map[string]int, error)
56
}
57

58
// UserQuestionStats represents per-user question statistics
59
type UserQuestionStats struct {
60
    UserID           int                `json:"user_id"`
61
    TotalAnswered    int                `json:"total_answered"`
62
    CorrectAnswers   int                `json:"correct_answers"`
63
    IncorrectAnswers int                `json:"incorrect_answers"`
64
    AccuracyRate     float64            `json:"accuracy_rate"`
65
    AnsweredByType   map[string]int     `json:"answered_by_type"`
66
    AnsweredByLevel  map[string]int     `json:"answered_by_level"`
67
    AccuracyByType   map[string]float64 `json:"accuracy_by_type"`
68
    AccuracyByLevel  map[string]float64 `json:"accuracy_by_level"`
69
    AvailableByType  map[string]int     `json:"available_by_type"`
70
    AvailableByLevel map[string]int     `json:"available_by_level"`
71
    RecentlyAnswered int                `json:"recently_answered"` // Within last hour
72
}
73

74
// contextutils.ErrQuestionNotFound is returned when a question does not exist in the database
75
// contextutils.ErrQuestionNotFound is now imported from contextutils
76

77
// LearningService provides methods for managing user learning progress
78
type LearningService struct {
79
    db     *sql.DB
80
    cfg    *config.Config
81
    logger *observability.Logger
82
}
83

84
// NewLearningServiceWithLogger creates a new LearningService with a logger
85
101x
func NewLearningServiceWithLogger(db *sql.DB, cfg *config.Config, logger *observability.Logger) *LearningService {
86
101x
    return &LearningService{
87
101x
        db:     db,
88
101x
        cfg:    cfg,
89
101x
        logger: logger,
90
101x
    }
91
101x
}
92

93
// RecordUserResponse records a user's response to a question and updates metrics
94
36x
func (s *LearningService) RecordUserResponse(ctx context.Context, response *models.UserResponse) (err error) {
95
36x
    ctx, span := observability.TraceLearningFunction(ctx, "record_user_response",
96
36x
        observability.AttributeUserID(response.UserID),
97
36x
        observability.AttributeQuestionID(response.QuestionID),
98
36x
        attribute.Bool("response.is_correct", response.IsCorrect),
99
36x
        attribute.Int("response.time_ms", response.ResponseTimeMs),
100
36x
    )
101
36x
    defer observability.FinishSpan(span, &err)
102
36x

103
36x
    query := `
104
36x
        INSERT INTO user_responses (user_id, question_id, user_answer_index, is_correct, response_time_ms)
105
36x
        VALUES ($1, $2, $3, $4, $5) RETURNING id
106
36x
    `
107
36x

108
36x
    var id int
109
36x
    err = s.db.QueryRowContext(ctx, query,
110
36x
        response.UserID,
111
36x
        response.QuestionID,
112
36x
        response.UserAnswerIndex,
113
36x
        response.IsCorrect,
114
36x
        response.ResponseTimeMs,
115
36x
    ).Scan(&id)
116
36x
    if err != nil {
117
1x
        return err
118
1x
    }
119

120
35x
    response.ID = id
121
35x

122
35x
    // Update performance metrics
123
35x
    return s.updatePerformanceMetrics(ctx, response)
124
}
125

126
35x
func (s *LearningService) updatePerformanceMetrics(ctx context.Context, response *models.UserResponse) (err error) {
127
35x
    ctx, span := observability.TraceLearningFunction(ctx, "update_performance_metrics",
128
35x
        observability.AttributeUserID(response.UserID),
129
35x
        observability.AttributeQuestionID(response.QuestionID),
130
35x
        attribute.Bool("response.is_correct", response.IsCorrect),
131
35x
    )
132
35x
    defer observability.FinishSpan(span, &err)
133
35x

134
35x
    // Get question details
135
35x
    var question *models.Question
136
35x
    question, err = s.getQuestionDetails(ctx, response.QuestionID)
137
35x
    if err != nil {
138
        return err
139
    }
140

141
    // Update or create performance metrics
142
35x
    query := `
143
35x
        INSERT INTO performance_metrics (
144
35x
            user_id, topic, language, level, total_attempts, correct_attempts,
145
35x
            average_response_time_ms, difficulty_adjustment, last_updated
146
35x
        )
147
35x
        VALUES ($1, $2, $3, $4, 1, $5, $6, 0.0, CURRENT_TIMESTAMP)
148
35x
        ON CONFLICT(user_id, topic, language, level) DO UPDATE SET
149
35x
            total_attempts = performance_metrics.total_attempts + 1,
150
35x
            correct_attempts = performance_metrics.correct_attempts + $7,
151
35x
            average_response_time_ms = (performance_metrics.average_response_time_ms * (performance_metrics.total_attempts - 1) + $8) / performance_metrics.total_attempts,
152
35x
            last_updated = CURRENT_TIMESTAMP
153
35x
    `
154
35x

155
35x
    correctIncrement := 0
156
35x
    if response.IsCorrect {
157
18x
        correctIncrement = 1
158
18x
    }
159

160
35x
    _, err = s.db.ExecContext(ctx, query,
161
35x
        response.UserID,
162
35x
        question.TopicCategory,
163
35x
        question.Language,
164
35x
        question.Level,
165
35x
        correctIncrement,                 // For initial correct_attempts in VALUES
166
35x
        float64(response.ResponseTimeMs), // For initial average_response_time_ms in VALUES
167
35x
        correctIncrement,                 // For correct_attempts increment in UPDATE
168
35x
        response.ResponseTimeMs,          // For average_response_time_ms calculation in UPDATE
169
35x
    )
170
35x

171
35x
    return err
172
}
173

174
// getUserByID is a lightweight helper for LearningService to fetch a user row.
175
4x
func (s *LearningService) getUserByID(ctx context.Context, userID int) (*models.User, error) {
176
4x
    query := `
177
4x
        SELECT id, username, email, timezone, password_hash, last_active,
178
4x
               preferred_language, current_level, ai_provider, ai_model,
179
4x
               ai_enabled, ai_api_key, created_at, updated_at
180
4x
        FROM users
181
4x
        WHERE id = $1
182
4x
    `
183
4x

184
4x
    var u models.User
185
4x
    err := s.db.QueryRowContext(ctx, query, userID).Scan(
186
4x
        &u.ID, &u.Username, &u.Email, &u.Timezone, &u.PasswordHash, &u.LastActive,
187
4x
        &u.PreferredLanguage, &u.CurrentLevel, &u.AIProvider, &u.AIModel,
188
4x
        &u.AIEnabled, &u.AIAPIKey, &u.CreatedAt, &u.UpdatedAt,
189
4x
    )
190
4x
    if err != nil {
191
        if err == sql.ErrNoRows {
192
            return nil, nil
193
        }
194
        return nil, err
195
    }
196
4x
    return &u, nil
197
}
198

199
35x
func (s *LearningService) getQuestionDetails(ctx context.Context, questionID int) (result0 *models.Question, err error) {
200
35x
    ctx, span := observability.TraceLearningFunction(ctx, "get_question_details",
201
35x
        observability.AttributeQuestionID(questionID),
202
35x
    )
203
35x
    defer observability.FinishSpan(span, &err)
204
35x

205
35x
    query := `SELECT type, language, level, topic_category FROM questions WHERE id = $1`
206
35x

207
35x
    question := &models.Question{}
208
35x
    var topicCategory sql.NullString
209
35x
    err = s.db.QueryRowContext(ctx, query, questionID).Scan(
210
35x
        &question.Type,
211
35x
        &question.Language,
212
35x
        &question.Level,
213
35x
        &topicCategory,
214
35x
    )
215
35x

216
35x
    if topicCategory.Valid {
217
31x
        question.TopicCategory = topicCategory.String
218
31x
    }
219

220
35x
    return question, err
221
}
222

223
// GetUserProgress retrieves comprehensive learning progress for a user
224
1x
func (s *LearningService) GetUserProgress(ctx context.Context, userID int) (result0 *models.UserProgress, err error) {
225
1x
    ctx, span := observability.TraceLearningFunction(ctx, "get_user_progress",
226
1x
        attribute.String("user.username", ""),
227
1x
        attribute.String("language", ""),
228
1x
        attribute.String("level", ""),
229
1x
    )
230
1x
    defer observability.FinishSpan(span, &err)
231
1x

232
1x
    progress := &models.UserProgress{
233
1x
        PerformanceByTopic: make(map[string]*models.PerformanceMetrics),
234
1x
    }
235
1x

236
1x
    // Get overall stats
237
1x
    overallQuery := `
238
1x
        SELECT
239
1x
            COUNT(*) as total,
240
1x
            COALESCE(SUM(CASE WHEN is_correct THEN 1 ELSE 0 END), 0) as correct
241
1x
        FROM user_responses
242
1x
        WHERE user_id = $1
243
1x
    `
244
1x

245
1x
    err = s.db.QueryRowContext(ctx, overallQuery, userID).Scan(
246
1x
        &progress.TotalQuestions,
247
1x
        &progress.CorrectAnswers,
248
1x
    )
249
1x

250
1x
    if err != nil && err != sql.ErrNoRows {
251
        return nil, err
252
    }
253

254
1x
    if progress.TotalQuestions > 0 {
255
1x
        progress.AccuracyRate = float64(progress.CorrectAnswers) / float64(progress.TotalQuestions) * 100
256
1x
    }
257

258
    // Get performance by topic
259
1x
    metricsQuery := `
260
1x
        SELECT id, topic, language, level, total_attempts, correct_attempts,
261
1x
               average_response_time_ms, difficulty_adjustment, last_updated
262
1x
        FROM performance_metrics
263
1x
        WHERE user_id = $1
264
1x
    `
265
1x

266
1x
    rows, err := s.db.QueryContext(ctx, metricsQuery, userID)
267
1x
    if err != nil {
268
        return nil, err
269
    }
270
1x
    defer func() {
271
1x
        if err := rows.Close(); err != nil {
272
            s.logger.Warn(ctx, "Failed to close rows", map[string]interface{}{"error": err.Error()})
273
        }
274
    }()
275

276
1x
    for rows.Next() {
277
1x
        metric := &models.PerformanceMetrics{UserID: userID}
278
1x
        err = rows.Scan(
279
1x
            &metric.ID,
280
1x
            &metric.Topic,
281
1x
            &metric.Language,
282
1x
            &metric.Level,
283
1x
            &metric.TotalAttempts,
284
1x
            &metric.CorrectAttempts,
285
1x
            &metric.AverageResponseTimeMs,
286
1x
            &metric.DifficultyAdjustment,
287
1x
            &metric.LastUpdated,
288
1x
        )
289
1x
        if err != nil {
290
            return nil, err
291
        }
292

293
1x
        key := metric.Topic + "_" + metric.Language + "_" + metric.Level
294
1x
        progress.PerformanceByTopic[key] = metric
295
    }
296

297
    // Identify weak areas (accuracy < 60%)
298
1x
    progress.WeakAreas = s.identifyWeakAreas(progress.PerformanceByTopic)
299
1x

300
1x
    // Get recent activity
301
1x
    progress.RecentActivity, err = s.getRecentActivity(ctx, userID, 10)
302
1x
    if err != nil {
303
        return nil, err
304
    }
305

306
    // Get current level from user
307
1x
    currentLevel, err := s.getCurrentUserLevel(ctx, userID)
308
1x
    if err != nil {
309
        return nil, err
310
    }
311
1x
    progress.CurrentLevel = currentLevel
312
1x

313
1x
    // Suggest level adjustment if needed
314
1x
    progress.SuggestedLevel = s.suggestLevelAdjustment(progress)
315
1x

316
1x
    return progress, nil
317
}
318

319
1x
func (s *LearningService) identifyWeakAreas(metrics map[string]*models.PerformanceMetrics) []string {
320
1x
    // Note: This is a pure function that doesn't need tracing since it doesn't make external calls
321
1x
    // But we could add tracing if we want to track the analysis performance
322
1x
    var weakAreas []string
323
1x

324
1x
    for key, metric := range metrics {
325
1x
        if metric.TotalAttempts > 0 && metric.AccuracyRate() < 60.0 && metric.TotalAttempts >= 3 {
326
            weakAreas = append(weakAreas, key)
327
        }
328
    }
329

330
1x
    return weakAreas
331
}
332

333
1x
func (s *LearningService) getRecentActivity(ctx context.Context, userID, limit int) (result0 []models.UserResponse, err error) {
334
1x
    ctx, span := observability.TraceLearningFunction(ctx, "get_recent_activity",
335
1x
        observability.AttributeUserID(userID),
336
1x
        attribute.Int("limit", limit),
337
1x
    )
338
1x
    defer observability.FinishSpan(span, &err)
339
1x

340
1x
    query := `
341
1x
        SELECT id, user_id, question_id, user_answer_index, is_correct, response_time_ms, created_at
342
1x
        FROM user_responses
343
1x
        WHERE user_id = $1
344
1x
        ORDER BY created_at DESC
345
1x
        LIMIT $2
346
1x
    `
347
1x

348
1x
    rows, err := s.db.QueryContext(ctx, query, userID, limit)
349
1x
    if err != nil {
350
        return nil, err
351
    }
352
1x
    defer func() {
353
1x
        if err := rows.Close(); err != nil {
354
            s.logger.Warn(ctx, "Failed to close rows", map[string]interface{}{"error": err.Error()})
355
        }
356
    }()
357

358
1x
    var responses []models.UserResponse
359
1x
    for rows.Next() {
360
3x
        var response models.UserResponse
361
3x
        err = rows.Scan(
362
3x
            &response.ID,
363
3x
            &response.UserID,
364
3x
            &response.QuestionID,
365
3x
            &response.UserAnswerIndex,
366
3x
            &response.IsCorrect,
367
3x
            &response.ResponseTimeMs,
368
3x
            &response.CreatedAt,
369
3x
        )
370
3x
        if err != nil {
371
            return nil, err
372
        }
373

374
3x
        responses = append(responses, response)
375
    }
376

377
1x
    return responses, nil
378
}
379

380
1x
func (s *LearningService) getCurrentUserLevel(ctx context.Context, userID int) (result0 string, err error) {
381
1x
    ctx, span := observability.TraceLearningFunction(ctx, "get_current_user_level",
382
1x
        observability.AttributeUserID(userID),
383
1x
    )
384
1x
    defer observability.FinishSpan(span, &err)
385
1x

386
1x
    query := `SELECT current_level FROM users WHERE id = $1`
387
1x

388
1x
    var level sql.NullString
389
1x
    err = s.db.QueryRowContext(ctx, query, userID).Scan(&level)
390
1x
    if err != nil {
391
        return "", err
392
    }
393

394
    // Return default level if NULL
395
1x
    if !level.Valid || level.String == "" {
396
        return "A1", nil // Default level
397
    }
398

399
1x
    return level.String, nil
400
}
401

402
13x
func (s *LearningService) suggestLevelAdjustment(progress *models.UserProgress) string {
403
13x
    // Note: This is a pure function that doesn't need tracing since it doesn't make external calls
404
13x
    // But we could add tracing if we want to track the analysis performance
405
13x
    if progress.TotalQuestions < 20 {
406
3x
        return "" // Not enough data
407
3x
    }
408

409
    // If accuracy is consistently high (>85%), suggest level up
410
5x
    if progress.AccuracyRate > 85.0 {
411
2x
        return s.getNextLevel(progress.CurrentLevel)
412
2x
    }
413

414
    // If accuracy is consistently low (<50%), suggest level down
415
3x
    if progress.AccuracyRate < 50.0 {
416
2x
        return s.getPreviousLevel(progress.CurrentLevel)
417
2x
    }
418

419
1x
    return ""
420
}
421

422
10x
func (s *LearningService) getNextLevel(currentLevel string) string {
423
10x
    // Note: This is a pure function that doesn't need tracing since it doesn't make external calls
424
10x
    levels := s.cfg.GetAllLevels()
425
10x

426
10x
    for i, level := range levels {
427
45x
        if level == currentLevel && i < len(levels)-1 {
428
8x
            return levels[i+1]
429
8x
        }
430
    }
431

432
2x
    return currentLevel
433
}
434

435
10x
func (s *LearningService) getPreviousLevel(currentLevel string) string {
436
10x
    // Note: This is a pure function that doesn't need tracing since it doesn't make external calls
437
10x
    levels := s.cfg.GetAllLevels()
438
10x

439
10x
    for i, level := range levels {
440
54x
        if level == currentLevel && i > 0 {
441
8x
            return levels[i-1]
442
8x
        }
443
    }
444

445
2x
    return currentLevel
446
}
447

448
// GetWeakestTopics returns the topics where the user performs poorest
449
1x
func (s *LearningService) GetWeakestTopics(ctx context.Context, userID, limit int) (result0 []*models.PerformanceMetrics, err error) {
450
1x
    ctx, span := observability.TraceLearningFunction(ctx, "get_weakest_topics",
451
1x
        observability.AttributeUserID(userID),
452
1x
        attribute.Int("limit", limit),
453
1x
    )
454
1x
    defer observability.FinishSpan(span, &err)
455
1x

456
1x
    query := `
457
1x
        SELECT id, topic, language, level, total_attempts, correct_attempts, average_response_time_ms, difficulty_adjustment, last_updated
458
1x
        FROM performance_metrics
459
1x
        WHERE user_id = $1 AND total_attempts >= 3
460
1x
        ORDER BY (correct_attempts * 1.0 / total_attempts) ASC, last_updated ASC
461
1x
        LIMIT $2
462
1x
    `
463
1x

464
1x
    rows, err := s.db.QueryContext(ctx, query, userID, limit)
465
1x
    if err != nil {
466
        return nil, err
467
    }
468
1x
    defer func() {
469
1x
        if err := rows.Close(); err != nil {
470
            s.logger.Warn(ctx, "Failed to close rows", map[string]interface{}{"error": err.Error()})
471
        }
472
    }()
473

474
1x
    var topics []*models.PerformanceMetrics
475
1x
    for rows.Next() {
476
3x
        metric := &models.PerformanceMetrics{UserID: userID}
477
3x
        err = rows.Scan(
478
3x
            &metric.ID,
479
3x
            &metric.Topic,
480
3x
            &metric.Language,
481
3x
            &metric.Level,
482
3x
            &metric.TotalAttempts,
483
3x
            &metric.CorrectAttempts,
484
3x
            &metric.AverageResponseTimeMs,
485
3x
            &metric.DifficultyAdjustment,
486
3x
            &metric.LastUpdated,
487
3x
        )
488
3x
        if err != nil {
489
            return nil, err
490
        }
491
3x
        topics = append(topics, metric)
492
    }
493

494
1x
    return topics, nil
495
}
496

497
// ShouldAvoidQuestion determines if a question should be avoided for a user
498
4x
func (s *LearningService) ShouldAvoidQuestion(ctx context.Context, userID, questionID int) (result0 bool, err error) {
499
4x
    ctx, span := observability.TraceLearningFunction(ctx, "should_avoid_question",
500
4x
        observability.AttributeUserID(userID),
501
4x
        observability.AttributeQuestionID(questionID),
502
4x
    )
503
4x
    defer observability.FinishSpan(span, &err)
504
4x

505
4x
    // Determine user's local 1-day window and convert to UTC timestamps
506
4x
    startUTC, endUTC, _, err := contextutils.UserLocalDayRange(ctx, userID, 1, s.getUserByID)
507
4x
    if err != nil {
508
        return false, contextutils.WrapError(err, "failed to compute user local day range")
509
    }
510

511
4x
    query := `
512
4x
        SELECT COUNT(*)
513
4x
        FROM user_responses
514
4x
        WHERE user_id = $1 AND question_id = $2 AND is_correct = true
515
4x
        AND created_at >= $3 AND created_at < $4
516
4x
    `
517
4x

518
4x
    var count int
519
4x
    err = s.db.QueryRowContext(ctx, query, userID, questionID, startUTC, endUTC).Scan(&count)
520
4x

521
4x
    span.SetAttributes(attribute.Bool("should_avoid", count > 0))
522
4x
    return count > 0, err
523
}
524

525
// GetUserQuestionStats returns comprehensive per-user question statistics
526
1x
func (s *LearningService) GetUserQuestionStats(ctx context.Context, userID int) (result0 *UserQuestionStats, err error) {
527
1x
    ctx, span := observability.TraceLearningFunction(ctx, "get_user_question_stats",
528
1x
        observability.AttributeUserID(userID),
529
1x
    )
530
1x
    defer observability.FinishSpan(span, &err)
531
1x

532
1x
    stats := &UserQuestionStats{
533
1x
        UserID:           userID,
534
1x
        AnsweredByType:   make(map[string]int),
535
1x
        AnsweredByLevel:  make(map[string]int),
536
1x
        AccuracyByType:   make(map[string]float64),
537
1x
        AccuracyByLevel:  make(map[string]float64),
538
1x
        AvailableByType:  make(map[string]int),
539
1x
        AvailableByLevel: make(map[string]int),
540
1x
    }
541
1x

542
1x
    // Get user's language and level preferences
543
1x
    var userLanguage, userLevel string
544
1x
    userQuery := `SELECT COALESCE(preferred_language, 'italian'), COALESCE(current_level, 'B1') FROM users WHERE id = $1`
545
1x
    err = s.db.QueryRowContext(ctx, userQuery, userID).Scan(&userLanguage, &userLevel)
546
1x
    if err != nil {
547
        return nil, err
548
    }
549

550
1x
    span.SetAttributes(
551
1x
        attribute.String("user.language", userLanguage),
552
1x
        attribute.String("user.level", userLevel),
553
1x
    )
554
1x

555
1x
    // Get questions answered by user with stats
556
1x
    answeredQuery := `
557
1x
        SELECT
558
1x
            q.type,
559
1x
            q.level,
560
1x
            COUNT(*) as total,
561
1x
            SUM(CASE WHEN ur.is_correct THEN 1 ELSE 0 END) as correct
562
1x
        FROM user_responses ur
563
1x
        JOIN questions q ON ur.question_id = q.id
564
1x
        WHERE ur.user_id = $1
565
1x
        GROUP BY q.type, q.level
566
1x
    `
567
1x

568
1x
    rows, err := s.db.QueryContext(ctx, answeredQuery, userID)
569
1x
    if err != nil {
570
        return nil, err
571
    }
572
1x
    defer func() {
573
1x
        if err := rows.Close(); err != nil {
574
            s.logger.Warn(ctx, "Failed to close rows", map[string]interface{}{"error": err.Error()})
575
        }
576
    }()
577

578
1x
    for rows.Next() {
579
1x
        var qType, level string
580
1x
        var total, correct int
581
1x

582
1x
        if err := rows.Scan(&qType, &level, &total, &correct); err != nil {
583
            return nil, err
584
        }
585

586
1x
        stats.AnsweredByType[qType] += total
587
1x
        stats.AnsweredByLevel[level] += total
588
1x
        stats.TotalAnswered += total
589
1x

590
1x
        // Calculate accuracy rates
591
1x
        accuracy := float64(correct) / float64(total) * 100
592
1x

593
1x
        // For type accuracy, we need to aggregate across levels
594
1x
        if _, exists := stats.AnsweredByType[qType]; exists {
595
1x
            // Recalculate accuracy for this type
596
1x
            typeQuery := `
597
1x
                SELECT
598
1x
                    COUNT(*) as total,
599
1x
                    SUM(CASE WHEN ur.is_correct THEN 1 ELSE 0 END) as correct
600
1x
                FROM user_responses ur
601
1x
                JOIN questions q ON ur.question_id = q.id
602
1x
                WHERE ur.user_id = $1 AND q.type = $2
603
1x
            `
604
1x
            var typeTotal, typeCorrect int
605
1x
            if err := s.db.QueryRowContext(ctx, typeQuery, userID, qType).Scan(&typeTotal, &typeCorrect); err != nil {
606
                s.logger.Warn(ctx, "Failed to scan type query result", map[string]interface{}{"error": err.Error()})
607
            }
608
1x
            if typeTotal > 0 {
609
1x
                stats.AccuracyByType[qType] = float64(typeCorrect) / float64(typeTotal) * 100
610
1x
            }
611
        } else {
612
            stats.AccuracyByType[qType] = accuracy
613
        }
614

615
        // For level accuracy
616
1x
        if _, exists := stats.AnsweredByLevel[level]; exists {
617
1x
            // Recalculate accuracy for this level
618
1x
            levelQuery := `
619
1x
                SELECT
620
1x
                    COUNT(*) as total,
621
1x
                    SUM(CASE WHEN ur.is_correct THEN 1 ELSE 0 END) as correct
622
1x
                FROM user_responses ur
623
1x
                JOIN questions q ON ur.question_id = q.id
624
1x
                WHERE ur.user_id = $1 AND q.level = $2
625
1x
            `
626
1x
            var levelTotal, levelCorrect int
627
1x
            if err := s.db.QueryRowContext(ctx, levelQuery, userID, level).Scan(&levelTotal, &levelCorrect); err != nil {
628
                s.logger.Warn(ctx, "Failed to scan level query result", map[string]interface{}{"error": err.Error()})
629
            }
630
1x
            if levelTotal > 0 {
631
1x
                stats.AccuracyByLevel[level] = float64(levelCorrect) / float64(levelTotal) * 100
632
1x
            }
633
        } else {
634
            stats.AccuracyByLevel[level] = accuracy
635
        }
636
    }
637

638
    // Get available questions (not answered by user) that belong to this user
639
1x
    availableQuery := `
640
1x
        SELECT
641
1x
            q.type,
642
1x
            q.level,
643
1x
            COUNT(*) as available
644
1x
        FROM questions q
645
1x
        JOIN user_questions uq ON uq.question_id = q.id
646
1x
        WHERE uq.user_id = $1
647
1x
        AND q.language = $2
648
1x
        AND q.status = 'active'
649
1x
        AND q.id NOT IN (
650
1x
            SELECT DISTINCT question_id
651
1x
            FROM user_responses
652
1x
            WHERE user_id = $3
653
1x
        )
654
1x
        GROUP BY q.type, q.level
655
1x
    `
656
1x

657
1x
    rows, err = s.db.QueryContext(ctx, availableQuery, userID, userLanguage, userID)
658
1x
    if err != nil {
659
        return nil, err
660
    }
661
1x
    defer func() {
662
1x
        if err := rows.Close(); err != nil {
663
            s.logger.Warn(ctx, "Failed to close rows", map[string]interface{}{"error": err.Error()})
664
        }
665
    }()
666

667
1x
    for rows.Next() {
668
        var qType, level string
669
        var available int
670

671
        if err := rows.Scan(&qType, &level, &available); err != nil {
672
            return nil, err
673
        }
674

675
        stats.AvailableByType[qType] += available
676
        stats.AvailableByLevel[level] += available
677
    }
678

679
    // Get recently answered questions (within last hour)
680
1x
    recentQuery := `
681
1x
        SELECT COUNT(*)
682
1x
        FROM user_responses ur
683
1x
        WHERE ur.user_id = $1
684
1x
        AND ur.created_at > NOW() - INTERVAL '1 hour'
685
1x
    `
686
1x

687
1x
    err = s.db.QueryRowContext(ctx, recentQuery, userID).Scan(&stats.RecentlyAnswered)
688
1x
    if err != nil {
689
        stats.RecentlyAnswered = 0 // Default to 0 if query fails
690
    }
691

692
    // Calculate overall correct/incorrect answers and accuracy rate
693
1x
    overallQuery := `
694
1x
        SELECT
695
1x
            COUNT(*) as total,
696
1x
            SUM(CASE WHEN is_correct THEN 1 ELSE 0 END) as correct
697
1x
        FROM user_responses
698
1x
        WHERE user_id = $1
699
1x
    `
700
1x

701
1x
    var total, correct int
702
1x
    err = s.db.QueryRowContext(ctx, overallQuery, userID).Scan(&total, &correct)
703
1x
    if err != nil {
704
        // Default values if query fails
705
        stats.CorrectAnswers = 0
706
        stats.IncorrectAnswers = 0
707
        stats.AccuracyRate = 0.0
708
    } else {
709
1x
        stats.CorrectAnswers = correct
710
1x
        stats.IncorrectAnswers = total - correct
711
1x
        if total > 0 {
712
1x
            stats.AccuracyRate = float64(correct) / float64(total) * 100
713
1x
        } else {
714
            stats.AccuracyRate = 0.0
715
        }
716
    }
717

718
1x
    return stats, nil
719
}
720

721
// PRIORITY SYSTEM METHODS
722

723
// RecordAnswerWithPriority records a user's response and updates priority scores
724
1x
func (s *LearningService) RecordAnswerWithPriority(ctx context.Context, userID, questionID, answerIndex int, isCorrect bool, responseTime int) error {
725
1x
    // Create user response object
726
1x
    response := &models.UserResponse{
727
1x
        UserID:          userID,
728
1x
        QuestionID:      questionID,
729
1x
        UserAnswerIndex: answerIndex,
730
1x
        IsCorrect:       isCorrect,
731
1x
        ResponseTimeMs:  responseTime,
732
1x
        CreatedAt:       time.Now(),
733
1x
    }
734
1x

735
1x
    // Use existing RecordUserResponse method
736
1x
    err := s.RecordUserResponse(ctx, response)
737
1x
    if err != nil {
738
        return contextutils.WrapError(err, "failed to record user response")
739
    }
740

741
    // Update priority score in background
742
1x
    go s.updatePriorityScoreAsync(ctx, userID, questionID)
743
1x

744
1x
    return nil
745
}
746

747
// RecordAnswerWithPriorityReturningID records a user's response, updates priority async, and returns the new user_responses ID
748
2x
func (s *LearningService) RecordAnswerWithPriorityReturningID(ctx context.Context, userID, questionID, answerIndex int, isCorrect bool, responseTime int) (int, error) {
749
2x
    response := &models.UserResponse{
750
2x
        UserID:          userID,
751
2x
        QuestionID:      questionID,
752
2x
        UserAnswerIndex: answerIndex,
753
2x
        IsCorrect:       isCorrect,
754
2x
        ResponseTimeMs:  responseTime,
755
2x
        CreatedAt:       time.Now(),
756
2x
    }
757
2x

758
2x
    // Insert and get ID
759
2x
    if err := s.RecordUserResponse(ctx, response); err != nil {
760
        return 0, contextutils.WrapError(err, "failed to record user response")
761
    }
762

763
    // Update priority score in background
764
2x
    go s.updatePriorityScoreAsync(ctx, userID, questionID)
765
2x

766
2x
    return response.ID, nil
767
}
768

769
// MarkQuestionAsKnown marks a question as known for a user with optional confidence level
770
12x
func (s *LearningService) MarkQuestionAsKnown(ctx context.Context, userID, questionID int, confidenceLevel *int) (err error) {
771
12x
    ctx, span := observability.TraceLearningFunction(ctx, "mark_question_as_known",
772
12x
        observability.AttributeUserID(userID),
773
12x
        observability.AttributeQuestionID(questionID),
774
12x
    )
775
12x
    defer observability.FinishSpan(span, &err)
776
12x

777
12x
    // DEBUG: Log the attempt
778
12x
    s.logger.Debug(ctx, "MarkQuestionAsKnown called", map[string]interface{}{
779
12x
        "user_id":     userID,
780
12x
        "question_id": questionID,
781
12x
    })
782
12x

783
12x
    // Update user_question_metadata table with confidence level
784
12x
    _, err = s.db.ExecContext(ctx, `
785
12x
        INSERT INTO user_question_metadata (user_id, question_id, marked_as_known, marked_as_known_at, confidence_level, created_at, updated_at)
786
12x
        VALUES ($1, $2, TRUE, NOW(), $3, NOW(), NOW())
787
12x
        ON CONFLICT (user_id, question_id) DO UPDATE
788
12x
        SET marked_as_known = TRUE, marked_as_known_at = NOW(), confidence_level = $3, updated_at = NOW()
789
12x
    `, userID, questionID, confidenceLevel)
790
12x
    if err != nil {
791
        // DEBUG: Log the actual error
792
        s.logger.Debug(ctx, "MarkQuestionAsKnown error", map[string]interface{}{
793
            "user_id":     userID,
794
            "question_id": questionID,
795
            "error":       err.Error(),
796
            "error_type":  fmt.Sprintf("%T", err),
797
        })
798

799
        if isForeignKeyConstraintViolation(err) {
800
            s.logger.Debug(ctx, "Foreign key constraint violation detected", map[string]interface{}{
801
                "user_id":     userID,
802
                "question_id": questionID,
803
            })
804
            return contextutils.ErrQuestionNotFound
805
        }
806
        s.logger.Debug(ctx, "Not a foreign key constraint violation, returning original error", map[string]interface{}{
807
            "user_id":     userID,
808
            "question_id": questionID,
809
        })
810
        return err
811
    }
812

813
12x
    s.logger.Debug(ctx, "MarkQuestionAsKnown succeeded", map[string]interface{}{
814
12x
        "user_id":     userID,
815
12x
        "question_id": questionID,
816
12x
    })
817
12x

818
12x
    // Update priority score in background so the new confidence affects selection immediately
819
12x
    go s.updatePriorityScoreAsync(ctx, userID, questionID)
820
12x
    return nil
821
}
822

823
// GetUserLearningPreferences retrieves user learning preferences
824
332x
func (s *LearningService) GetUserLearningPreferences(ctx context.Context, userID int) (result0 *models.UserLearningPreferences, err error) {
825
332x
    ctx, span := observability.TraceLearningFunction(ctx, "get_user_learning_preferences",
826
332x
        observability.AttributeUserID(userID),
827
332x
    )
828
332x
    defer observability.FinishSpan(span, &err)
829
332x

830
332x
    var prefs models.UserLearningPreferences
831
332x
    err = s.db.QueryRowContext(ctx, `
832
332x
        SELECT id, user_id, focus_on_weak_areas, include_review_questions, fresh_question_ratio,
833
332x
               known_question_penalty, review_interval_days, weak_area_boost, daily_reminder_enabled,
834
332x
               tts_voice, last_daily_reminder_sent, daily_goal, created_at, updated_at
835
332x
        FROM user_learning_preferences
836
332x
        WHERE user_id = $1
837
332x
    `, userID).Scan(
838
332x
        &prefs.ID, &prefs.UserID, &prefs.FocusOnWeakAreas, &prefs.IncludeReviewQuestions,
839
332x
        &prefs.FreshQuestionRatio, &prefs.KnownQuestionPenalty, &prefs.ReviewIntervalDays,
840
332x
        &prefs.WeakAreaBoost, &prefs.DailyReminderEnabled,
841
332x
        &prefs.TTSVoice,
842
332x
        &prefs.LastDailyReminderSent,
843
332x
        &prefs.DailyGoal,
844
332x
        &prefs.CreatedAt, &prefs.UpdatedAt,
845
332x
    )
846
332x

847
332x
    if err == sql.ErrNoRows {
848
32x
        // Check if user exists before creating default preferences
849
32x
        var userExists bool
850
32x
        err = s.db.QueryRowContext(ctx, "SELECT EXISTS(SELECT 1 FROM users WHERE id = $1)", userID).Scan(&userExists)
851
32x
        if err != nil {
852
5x
            return nil, contextutils.WrapError(err, "failed to check if user exists")
853
5x
        }
854
27x
        if !userExists {
855
            return nil, contextutils.WrapErrorf(contextutils.ErrRecordNotFound, "user %d not found", userID)
856
        }
857
        // Create default preferences if none exist
858
27x
        return s.createDefaultPreferences(ctx, userID)
859
    }
860

861
300x
    if err != nil {
862
        return nil, contextutils.WrapError(err, "failed to get user preferences")
863
    }
864

865
300x
    return &prefs, nil
866
}
867

868
// UpdateLastDailyReminderSent updates the last daily reminder sent timestamp for a user
869
3x
func (s *LearningService) UpdateLastDailyReminderSent(ctx context.Context, userID int) (err error) {
870
3x
    ctx, span := observability.TraceLearningFunction(ctx, "update_last_daily_reminder_sent",
871
3x
        observability.AttributeUserID(userID),
872
3x
    )
873
3x
    defer observability.FinishSpan(span, &err)
874
3x

875
3x
    // Use INSERT ... ON CONFLICT to create the record if it doesn't exist
876
3x
    _, err = s.db.ExecContext(ctx, `
877
3x
        INSERT INTO user_learning_preferences (user_id, last_daily_reminder_sent, updated_at)
878
3x
        VALUES ($1, NOW(), NOW())
879
3x
        ON CONFLICT (user_id) DO UPDATE SET
880
3x
            last_daily_reminder_sent = NOW(),
881
3x
            updated_at = NOW()
882
3x
    `, userID)
883
3x
    if err != nil {
884
        return contextutils.WrapError(err, "failed to update last daily reminder sent")
885
    }
886

887
3x
    return nil
888
}
889

890
// UpdateUserLearningPreferences updates user learning preferences
891
9x
func (s *LearningService) UpdateUserLearningPreferences(ctx context.Context, userID int, prefs *models.UserLearningPreferences) (result0 *models.UserLearningPreferences, err error) {
892
9x
    ctx, span := observability.TraceLearningFunction(ctx, "update_user_learning_preferences",
893
9x
        observability.AttributeUserID(userID),
894
9x
        attribute.Bool("prefs.focus_on_weak_areas", prefs.FocusOnWeakAreas),
895
9x
        attribute.Bool("prefs.include_review_questions", prefs.IncludeReviewQuestions),
896
9x
        attribute.Float64("prefs.fresh_question_ratio", prefs.FreshQuestionRatio),
897
9x
        attribute.Float64("prefs.known_question_penalty", prefs.KnownQuestionPenalty),
898
9x
        attribute.Int("prefs.review_interval_days", prefs.ReviewIntervalDays),
899
9x
        attribute.Float64("prefs.weak_area_boost", prefs.WeakAreaBoost),
900
9x
    )
901
9x
    defer func() {
902
9x
        if err != nil {
903
            span.RecordError(err, trace.WithStackTrace(true))
904
            span.SetStatus(codes.Error, err.Error())
905
        }
906
9x
        span.End()
907
    }()
908

909
9x
    var updatedPrefs models.UserLearningPreferences
910
9x
    err = s.db.QueryRowContext(ctx, `
911
9x
        UPDATE user_learning_preferences
912
9x
        SET focus_on_weak_areas = $2, include_review_questions = $3, fresh_question_ratio = $4,
913
9x
            known_question_penalty = $5, review_interval_days = $6, weak_area_boost = $7,
914
9x
            daily_reminder_enabled = $8, tts_voice = $9, daily_goal = COALESCE(NULLIF($10, 0), daily_goal), updated_at = NOW()
915
9x
        WHERE user_id = $1
916
9x
        RETURNING id, user_id, focus_on_weak_areas, include_review_questions, fresh_question_ratio,
917
9x
                  known_question_penalty, review_interval_days, weak_area_boost, daily_reminder_enabled,
918
9x
                  tts_voice, last_daily_reminder_sent, daily_goal, created_at, updated_at
919
9x
    `, userID, prefs.FocusOnWeakAreas, prefs.IncludeReviewQuestions, prefs.FreshQuestionRatio,
920
9x
        prefs.KnownQuestionPenalty, prefs.ReviewIntervalDays, prefs.WeakAreaBoost, prefs.DailyReminderEnabled, prefs.TTSVoice, prefs.DailyGoal).Scan(
921
9x
        &updatedPrefs.ID, &updatedPrefs.UserID, &updatedPrefs.FocusOnWeakAreas, &updatedPrefs.IncludeReviewQuestions,
922
9x
        &updatedPrefs.FreshQuestionRatio, &updatedPrefs.KnownQuestionPenalty, &updatedPrefs.ReviewIntervalDays,
923
9x
        &updatedPrefs.WeakAreaBoost, &updatedPrefs.DailyReminderEnabled, &updatedPrefs.TTSVoice, &updatedPrefs.LastDailyReminderSent,
924
9x
        &updatedPrefs.DailyGoal, &updatedPrefs.CreatedAt, &updatedPrefs.UpdatedAt,
925
9x
    )
926
9x

927
9x
    if err == sql.ErrNoRows {
928
7x
        // If no preferences exist, create them with the provided values
929
7x
        return s.createPreferencesWithValues(ctx, userID, prefs)
930
7x
    }
931

932
2x
    if err != nil {
933
        return nil, contextutils.WrapError(err, "failed to update user preferences")
934
    }
935

936
2x
    return &updatedPrefs, nil
937
}
938

939
// createPreferencesWithValues creates learning preferences for a user with the provided values
940
7x
func (s *LearningService) createPreferencesWithValues(ctx context.Context, userID int, prefs *models.UserLearningPreferences) (result0 *models.UserLearningPreferences, err error) {
941
7x
    ctx, span := observability.TraceLearningFunction(ctx, "create_preferences_with_values",
942
7x
        observability.AttributeUserID(userID),
943
7x
    )
944
7x
    defer func() {
945
7x
        if err != nil {
946
            span.RecordError(err, trace.WithStackTrace(true))
947
            span.SetStatus(codes.Error, err.Error())
948
        }
949
7x
        span.End()
950
    }()
951

952
    // Use the provided values, falling back to defaults for any missing fields
953
7x
    defaultPrefs := s.GetDefaultLearningPreferences()
954
7x
    prefs.UserID = userID
955
7x

956
7x
    // Merge provided values with defaults
957
7x
    if prefs.FocusOnWeakAreas == defaultPrefs.FocusOnWeakAreas && !prefs.FocusOnWeakAreas {
958
        prefs.FocusOnWeakAreas = defaultPrefs.FocusOnWeakAreas
959
    }
960
7x
    if prefs.IncludeReviewQuestions == defaultPrefs.IncludeReviewQuestions && !prefs.IncludeReviewQuestions {
961
        prefs.IncludeReviewQuestions = defaultPrefs.IncludeReviewQuestions
962
    }
963
7x
    if prefs.FreshQuestionRatio == 0 {
964
2x
        prefs.FreshQuestionRatio = defaultPrefs.FreshQuestionRatio
965
2x
    }
966
7x
    if prefs.KnownQuestionPenalty == 0 {
967
2x
        prefs.KnownQuestionPenalty = defaultPrefs.KnownQuestionPenalty
968
2x
    }
969
7x
    if prefs.ReviewIntervalDays == 0 {
970
2x
        prefs.ReviewIntervalDays = defaultPrefs.ReviewIntervalDays
971
2x
    }
972
7x
    if prefs.WeakAreaBoost == 0 {
973
2x
        prefs.WeakAreaBoost = defaultPrefs.WeakAreaBoost
974
2x
    }
975
7x
    if prefs.DailyGoal == 0 {
976
7x
        prefs.DailyGoal = defaultPrefs.DailyGoal
977
7x
    }
978

979
    // Try to insert with ON CONFLICT DO NOTHING to handle race conditions
980
7x
    _, err = s.db.ExecContext(ctx, `
981
7x
        INSERT INTO user_learning_preferences (user_id, focus_on_weak_areas, include_review_questions,
982
7x
                                               fresh_question_ratio, known_question_penalty,
983
7x
                                               review_interval_days, weak_area_boost, daily_reminder_enabled,
984
7x
                                               tts_voice, daily_goal, created_at, updated_at)
985
7x
        VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, NOW(), NOW())
986
7x
        ON CONFLICT (user_id) DO NOTHING
987
7x
    `, userID, prefs.FocusOnWeakAreas, prefs.IncludeReviewQuestions,
988
7x
        prefs.FreshQuestionRatio, prefs.KnownQuestionPenalty,
989
7x
        prefs.ReviewIntervalDays, prefs.WeakAreaBoost, prefs.DailyReminderEnabled, prefs.TTSVoice, prefs.DailyGoal)
990
7x
    if err != nil {
991
        return nil, contextutils.WrapError(err, "failed to create preferences with values")
992
    }
993

994
    // Now fetch the preferences (either the ones we just created or the ones created by another concurrent request)
995
7x
    err = s.db.QueryRowContext(ctx, `
996
7x
        SELECT id, user_id, focus_on_weak_areas, include_review_questions, fresh_question_ratio,
997
7x
               known_question_penalty, review_interval_days, weak_area_boost, daily_reminder_enabled,
998
7x
               tts_voice, last_daily_reminder_sent, daily_goal, created_at, updated_at
999
7x
        FROM user_learning_preferences
1000
7x
        WHERE user_id = $1
1001
7x
    `, userID).Scan(
1002
7x
        &prefs.ID, &prefs.UserID, &prefs.FocusOnWeakAreas, &prefs.IncludeReviewQuestions,
1003
7x
        &prefs.FreshQuestionRatio, &prefs.KnownQuestionPenalty, &prefs.ReviewIntervalDays,
1004
7x
        &prefs.WeakAreaBoost, &prefs.DailyReminderEnabled, &prefs.TTSVoice, &prefs.LastDailyReminderSent,
1005
7x
        &prefs.DailyGoal, &prefs.CreatedAt, &prefs.UpdatedAt,
1006
7x
    )
1007
7x
    if err != nil {
1008
        return nil, contextutils.WrapError(err, "failed to fetch created preferences")
1009
    }
1010

1011
7x
    return prefs, nil
1012
}
1013

1014
// createDefaultPreferences creates default learning preferences for a user
1015
27x
func (s *LearningService) createDefaultPreferences(ctx context.Context, userID int) (result0 *models.UserLearningPreferences, err error) {
1016
27x
    ctx, span := observability.TraceLearningFunction(ctx, "create_default_preferences",
1017
27x
        observability.AttributeUserID(userID),
1018
27x
    )
1019
27x
    defer func() {
1020
27x
        if err != nil {
1021
            span.RecordError(err, trace.WithStackTrace(true))
1022
            span.SetStatus(codes.Error, err.Error())
1023
        }
1024
27x
        span.End()
1025
    }()
1026

1027
27x
    defaultPrefs := s.GetDefaultLearningPreferences()
1028
27x
    defaultPrefs.UserID = userID
1029
27x

1030
27x
    // Try to insert with ON CONFLICT DO NOTHING to handle race conditions
1031
27x
    _, err = s.db.ExecContext(ctx, `
1032
27x
        INSERT INTO user_learning_preferences (user_id, focus_on_weak_areas, include_review_questions,
1033
27x
                                               fresh_question_ratio, known_question_penalty,
1034
27x
                                               review_interval_days, weak_area_boost, daily_reminder_enabled,
1035
27x
                                               tts_voice, daily_goal, created_at, updated_at)
1036
27x
        VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, NOW(), NOW())
1037
27x
        ON CONFLICT (user_id) DO NOTHING
1038
27x
    `, userID, defaultPrefs.FocusOnWeakAreas, defaultPrefs.IncludeReviewQuestions,
1039
27x
        defaultPrefs.FreshQuestionRatio, defaultPrefs.KnownQuestionPenalty,
1040
27x
        defaultPrefs.ReviewIntervalDays, defaultPrefs.WeakAreaBoost, defaultPrefs.DailyReminderEnabled, defaultPrefs.TTSVoice, defaultPrefs.DailyGoal)
1041
27x
    if err != nil {
1042
        return nil, contextutils.WrapError(err, "failed to create default preferences")
1043
    }
1044

1045
    // Now fetch the preferences (either the ones we just created or the ones created by another concurrent request)
1046
27x
    err = s.db.QueryRowContext(ctx, `
1047
27x
        SELECT id, user_id, focus_on_weak_areas, include_review_questions, fresh_question_ratio,
1048
27x
               known_question_penalty, review_interval_days, weak_area_boost, daily_reminder_enabled,
1049
27x
               tts_voice, last_daily_reminder_sent, daily_goal, created_at, updated_at
1050
27x
        FROM user_learning_preferences
1051
27x
        WHERE user_id = $1
1052
27x
    `, userID).Scan(
1053
27x
        &defaultPrefs.ID, &defaultPrefs.UserID, &defaultPrefs.FocusOnWeakAreas, &defaultPrefs.IncludeReviewQuestions,
1054
27x
        &defaultPrefs.FreshQuestionRatio, &defaultPrefs.KnownQuestionPenalty, &defaultPrefs.ReviewIntervalDays,
1055
27x
        &defaultPrefs.WeakAreaBoost, &defaultPrefs.DailyReminderEnabled, &defaultPrefs.TTSVoice, &defaultPrefs.LastDailyReminderSent,
1056
27x
        &defaultPrefs.DailyGoal, &defaultPrefs.CreatedAt, &defaultPrefs.UpdatedAt,
1057
27x
    )
1058
27x
    if err != nil {
1059
        return nil, contextutils.WrapError(err, "failed to fetch created preferences")
1060
    }
1061

1062
27x
    return defaultPrefs, nil
1063
}
1064

1065
// GetDefaultLearningPreferences returns default learning preferences
1066
34x
func (s *LearningService) GetDefaultLearningPreferences() *models.UserLearningPreferences {
1067
34x
    return &models.UserLearningPreferences{
1068
34x
        FocusOnWeakAreas:       true,
1069
34x
        IncludeReviewQuestions: true,
1070
34x
        FreshQuestionRatio:     0.3,
1071
34x
        KnownQuestionPenalty:   0.1,
1072
34x
        ReviewIntervalDays:     7,
1073
34x
        WeakAreaBoost:          2.0,
1074
34x
        DailyReminderEnabled:   false, // Default to false for daily reminders
1075
34x
        DailyGoal:              10,
1076
34x
        TTSVoice:               "",
1077
34x
    }
1078
34x
}
1079

1080
// CalculatePriorityScore calculates priority score for a specific question for a user
1081
28x
func (s *LearningService) CalculatePriorityScore(ctx context.Context, userID, questionID int) (result0 float64, err error) {
1082
28x
    ctx, span := observability.TraceLearningFunction(ctx, "calculate_priority_score",
1083
28x
        observability.AttributeUserID(userID),
1084
28x
        observability.AttributeQuestionID(questionID),
1085
28x
    )
1086
28x
    defer func() {
1087
28x
        if err != nil {
1088
7x
            span.RecordError(err, trace.WithStackTrace(true))
1089
7x
            span.SetStatus(codes.Error, err.Error())
1090
7x
        }
1091
28x
        span.End()
1092
    }()
1093

1094
    // Get user preferences
1095
28x
    prefs, err := s.GetUserLearningPreferences(ctx, userID)
1096
28x
    if err != nil {
1097
5x
        return 0, contextutils.WrapErrorf(contextutils.ErrDatabaseQuery, "failed to get user preferences: %w", err)
1098
5x
    }
1099

1100
    // Get user's performance history for this question
1101
23x
    performance, err := s.getQuestionPerformance(ctx, userID, questionID)
1102
23x
    if err != nil {
1103
2x
        return 0, contextutils.WrapErrorf(contextutils.ErrDatabaseQuery, "failed to get question performance: %w", err)
1104
2x
    }
1105

1106
    // Calculate components
1107
21x
    baseScore := 100.0
1108
21x
    performanceMultiplier := s.calculatePerformanceMultiplier(performance, prefs.WeakAreaBoost)
1109
21x
    spacedRepetitionBoost := s.calculateSpacedRepetitionBoost(performance.LastSeenAt)
1110
21x
    userPreferenceMultiplier := s.calculateUserPreferenceMultiplier(performance, prefs)
1111
21x
    freshnessBoost := s.calculateFreshnessBoost(performance.TimesAnswered)
1112
21x

1113
21x
    // Final score with bounds checking
1114
21x
    finalScore := baseScore * performanceMultiplier * spacedRepetitionBoost * userPreferenceMultiplier * freshnessBoost
1115
21x

1116
21x
    // Apply bounds to prevent extreme values
1117
21x
    if finalScore < 1.0 {
1118
        finalScore = 1.0
1119
    } else if finalScore > 1000.0 {
1120
        finalScore = 1000.0
1121
    }
1122

1123
21x
    return finalScore, nil
1124
}
1125

1126
// updatePriorityScoreAsync updates priority score for a question asynchronously
1127
16x
func (s *LearningService) updatePriorityScoreAsync(ctx context.Context, userID, questionID int) {
1128
16x
    ctx, span := observability.TraceLearningFunction(ctx, "update_priority_score_async",
1129
16x
        observability.AttributeUserID(userID),
1130
16x
        observability.AttributeQuestionID(questionID),
1131
16x
    )
1132
16x
    defer span.End()
1133
16x

1134
16x
    score, err := s.CalculatePriorityScore(ctx, userID, questionID)
1135
16x
    if err != nil {
1136
7x
        s.logger.Error(ctx, "Failed to calculate priority score", err, map[string]interface{}{
1137
7x
            "user_id":     userID,
1138
7x
            "question_id": questionID,
1139
7x
        })
1140
7x
        return
1141
7x
    }
1142

1143
    // Update or insert priority score
1144
9x
    _, err = s.db.ExecContext(ctx, `
1145
9x
        INSERT INTO question_priority_scores (user_id, question_id, priority_score, last_calculated_at, created_at, updated_at)
1146
9x
        VALUES ($1, $2, $3, NOW(), NOW(), NOW())
1147
9x
        ON CONFLICT (user_id, question_id) DO UPDATE
1148
9x
        SET priority_score = $3, last_calculated_at = NOW(), updated_at = NOW()
1149
9x
    `, userID, questionID, score)
1150
9x
    if err != nil {
1151
        s.logger.Error(ctx, "Failed to update priority score", err, map[string]interface{}{
1152
            "user_id":     userID,
1153
            "question_id": questionID,
1154
            "score":       score,
1155
        })
1156
    }
1157
}
1158

1159
// QuestionPerformance represents performance data for a specific question
1160
type QuestionPerformance struct {
1161
    TimesAnswered   int
1162
    CorrectAnswers  int
1163
    LastSeenAt      *time.Time
1164
    MarkedAsKnown   bool
1165
    MarkedAsKnownAt *time.Time
1166
    ConfidenceLevel *int
1167
}
1168

1169
// getQuestionPerformance retrieves performance data for a specific question
1170
23x
func (s *LearningService) getQuestionPerformance(ctx context.Context, userID, questionID int) (result0 *QuestionPerformance, err error) {
1171
23x
    ctx, span := observability.TraceLearningFunction(ctx, "get_question_performance",
1172
23x
        observability.AttributeUserID(userID),
1173
23x
        observability.AttributeQuestionID(questionID),
1174
23x
    )
1175
23x
    defer func() {
1176
23x
        if err != nil {
1177
2x
            span.RecordError(err, trace.WithStackTrace(true))
1178
2x
            span.SetStatus(codes.Error, err.Error())
1179
2x
        }
1180
23x
        span.End()
1181
    }()
1182

1183
23x
    performance := &QuestionPerformance{}
1184
23x

1185
23x
    // Get response statistics
1186
23x
    err = s.db.QueryRowContext(ctx, `
1187
23x
        SELECT
1188
23x
            COUNT(*) as times_answered,
1189
23x
            COALESCE(SUM(CASE WHEN is_correct THEN 1 ELSE 0 END), 0) as correct_answers,
1190
23x
            MAX(created_at) as last_seen_at
1191
23x
        FROM user_responses
1192
23x
        WHERE user_id = $1 AND question_id = $2
1193
23x
    `, userID, questionID).Scan(
1194
23x
        &performance.TimesAnswered,
1195
23x
        &performance.CorrectAnswers,
1196
23x
        &performance.LastSeenAt,
1197
23x
    )
1198
23x

1199
23x
    if err != nil && err != sql.ErrNoRows {
1200
2x
        return nil, contextutils.WrapErrorf(contextutils.ErrDatabaseQuery, "failed to get response statistics: %w", err)
1201
2x
    }
1202

1203
    // Get metadata
1204
21x
    var markedAsKnownAt sql.NullTime
1205
21x
    var confidenceLevel sql.NullInt32
1206
21x
    err = s.db.QueryRowContext(ctx, `
1207
21x
        SELECT marked_as_known, marked_as_known_at, confidence_level
1208
21x
        FROM user_question_metadata
1209
21x
        WHERE user_id = $1 AND question_id = $2
1210
21x
    `, userID, questionID).Scan(&performance.MarkedAsKnown, &markedAsKnownAt, &confidenceLevel)
1211
21x

1212
21x
    if err != nil && err != sql.ErrNoRows {
1213
        return nil, contextutils.WrapErrorf(contextutils.ErrDatabaseQuery, "failed to get question metadata: %w", err)
1214
    }
1215

1216
21x
    if markedAsKnownAt.Valid {
1217
15x
        performance.MarkedAsKnownAt = &markedAsKnownAt.Time
1218
15x
    }
1219

1220
21x
    if confidenceLevel.Valid {
1221
15x
        level := int(confidenceLevel.Int32)
1222
15x
        performance.ConfidenceLevel = &level
1223
15x
    }
1224

1225
21x
    return performance, nil
1226
}
1227

1228
// calculatePerformanceMultiplier calculates the performance-based multiplier
1229
21x
func (s *LearningService) calculatePerformanceMultiplier(performance *QuestionPerformance, weakAreaBoost float64) float64 {
1230
21x
    // Note: This is a pure function that doesn't need tracing since it doesn't make external calls
1231
21x
    if performance.TimesAnswered == 0 {
1232
16x
        return 1.0 // Neutral for new questions
1233
16x
    }
1234

1235
5x
    errorRate := float64(performance.TimesAnswered-performance.CorrectAnswers) / float64(performance.TimesAnswered)
1236
5x
    successRate := float64(performance.CorrectAnswers) / float64(performance.TimesAnswered)
1237
5x

1238
5x
    // Apply weak area boost for questions with high error rates
1239
5x
    multiplier := 1.0 + (errorRate * weakAreaBoost) - (successRate * 0.5)
1240
5x

1241
5x
    // Apply bounds to prevent extreme values
1242
5x
    if multiplier < 0.1 {
1243
        multiplier = 0.1
1244
    } else if multiplier > 10.0 {
1245
        multiplier = 10.0
1246
    }
1247

1248
5x
    return multiplier
1249
}
1250

1251
// calculateSpacedRepetitionBoost calculates the spaced repetition boost
1252
21x
func (s *LearningService) calculateSpacedRepetitionBoost(lastSeenAt *time.Time) float64 {
1253
21x
    // Note: This is a pure function that doesn't need tracing since it doesn't make external calls
1254
21x
    if lastSeenAt == nil {
1255
16x
        return 1.0 // No boost for never-seen questions
1256
16x
    }
1257

1258
5x
    daysSinceLastSeen := time.Since(*lastSeenAt).Hours() / 24.0
1259
5x
    boost := 1.0 + (daysSinceLastSeen * 0.1)
1260
5x

1261
5x
    // Cap the boost at 5.0x multiplier
1262
5x
    return math.Min(boost, 5.0)
1263
}
1264

1265
// calculateUserPreferenceMultiplier calculates how user preference ("mark known" with confidence)
1266
// influences question priority.
1267
//
1268
// New policy:
1269
// - Confidence 1â2: show MORE (boost priority) â multipliers > 1
1270
// - Confidence 3: neutral â multiplier = 1
1271
// - Confidence 4â5: show LESS (reduce priority) â multiplier < 1 using KnownQuestionPenalty
1272
21x
func (s *LearningService) calculateUserPreferenceMultiplier(performance *QuestionPerformance, prefs *models.UserLearningPreferences) float64 {
1273
21x
    // Note: This is a pure function that doesn't need tracing since it doesn't make external calls
1274
21x
    if performance.MarkedAsKnown {
1275
15x
        if performance.ConfidenceLevel != nil {
1276
15x
            switch *performance.ConfidenceLevel {
1277
4x
            case 1:
1278
4x
                // Low confidence â increase frequency noticeably
1279
4x
                return 1.25
1280
1x
            case 2:
1281
1x
                // Some confidence â slight increase in frequency
1282
1x
                return 1.10
1283
3x
            case 3:
1284
3x
                // Neutral â no change
1285
3x
                return 1.0
1286
2x
            case 4:
1287
2x
                // Very confident â decrease frequency using half of penalty
1288
2x
                return prefs.KnownQuestionPenalty * 0.5
1289
5x
            case 5:
1290
5x
                // Extremely confident â strong decrease using 10% of penalty
1291
5x
                return prefs.KnownQuestionPenalty * 0.1
1292
            default:
1293
                return 1.0
1294
            }
1295
        }
1296
        // Fallback when confidence not provided â use configured penalty
1297
        return prefs.KnownQuestionPenalty
1298
    }
1299
6x
    return 1.0
1300
}
1301

1302
// calculateFreshnessBoost calculates the freshness boost for new questions
1303
21x
func (s *LearningService) calculateFreshnessBoost(timesAnswered int) float64 {
1304
21x
    // Note: This is a pure function that doesn't need tracing since it doesn't make external calls
1305
21x
    if timesAnswered == 0 {
1306
16x
        return 1.5 // Boost for fresh questions
1307
16x
    }
1308
5x
    return 1.0
1309
}
1310

1311
// isForeignKeyConstraintViolation checks if the error is a foreign key constraint violation
1312
func isForeignKeyConstraintViolation(err error) bool {
1313
    if err == nil {
1314
        return false
1315
    }
1316

1317
    // Check for PostgreSQL foreign key constraint violation error code
1318
    if pqErr, ok := err.(*pq.Error); ok {
1319
        // PostgreSQL error code 23503 is for foreign key constraint violations
1320
        if pqErr.Code == "23503" {
1321
            return true
1322
        }
1323
    }
1324

1325
    // Also check for the error message pattern as a fallback
1326
    errorStr := err.Error()
1327
    return strings.Contains(errorStr, "violates foreign key constraint")
1328
}
1329

1330
// Analytics Methods
1331

1332
// GetPriorityScoreDistribution returns the distribution of priority scores
1333
1x
func (s *LearningService) GetPriorityScoreDistribution(ctx context.Context) (result0 map[string]interface{}, err error) {
1334
1x
    ctx, span := observability.TraceLearningFunction(ctx, "get_priority_score_distribution")
1335
1x
    defer func() {
1336
1x
        if err != nil {
1337
            span.RecordError(err, trace.WithStackTrace(true))
1338
            span.SetStatus(codes.Error, err.Error())
1339
        }
1340
1x
        span.End()
1341
    }()
1342

1343
1x
    query := `
1344
1x
        SELECT
1345
1x
            COUNT(CASE WHEN qps.priority_score > 200 THEN 1 END) as high,
1346
1x
            COUNT(CASE WHEN qps.priority_score BETWEEN 100 AND 200 THEN 1 END) as medium,
1347
1x
            COUNT(CASE WHEN qps.priority_score < 100 THEN 1 END) as low,
1348
1x
            AVG(qps.priority_score) as average
1349
1x
        FROM question_priority_scores qps
1350
1x
        JOIN questions q ON qps.question_id = q.id
1351
1x
        WHERE qps.priority_score > 0
1352
1x
    `
1353
1x

1354
1x
    var high, medium, low int
1355
1x
    var average sql.NullFloat64
1356
1x

1357
1x
    err = s.db.QueryRowContext(ctx, query).Scan(&high, &medium, &low, &average)
1358
1x
    if err != nil {
1359
        return nil, contextutils.WrapErrorf(contextutils.ErrDatabaseQuery, "failed to get priority score distribution: %w", err)
1360
    }
1361

1362
1x
    result := map[string]interface{}{
1363
1x
        "high":    high,
1364
1x
        "medium":  medium,
1365
1x
        "low":     low,
1366
1x
        "average": 0.0,
1367
1x
    }
1368
1x

1369
1x
    if average.Valid {
1370
1x
        result["average"] = average.Float64
1371
1x
    }
1372

1373
1x
    span.SetAttributes(
1374
1x
        attribute.Int("high_count", high),
1375
1x
        attribute.Int("medium_count", medium),
1376
1x
        attribute.Int("low_count", low),
1377
1x
        attribute.Float64("average_score", result["average"].(float64)),
1378
1x
    )
1379
1x

1380
1x
    return result, nil
1381
}
1382

1383
// GetHighPriorityQuestions returns the highest priority questions
1384
1x
func (s *LearningService) GetHighPriorityQuestions(ctx context.Context, limit int) (result0 []map[string]interface{}, err error) {
1385
1x
    ctx, span := observability.TraceLearningFunction(ctx, "get_high_priority_questions",
1386
1x
        attribute.Int("limit", limit),
1387
1x
    )
1388
1x
    defer func() {
1389
1x
        if err != nil {
1390
            span.RecordError(err, trace.WithStackTrace(true))
1391
            span.SetStatus(codes.Error, err.Error())
1392
        }
1393
1x
        span.End()
1394
    }()
1395

1396
1x
    query := `
1397
1x
        SELECT
1398
1x
            q.type as question_type,
1399
1x
            q.level,
1400
1x
            q.topic_category as topic,
1401
1x
            qps.priority_score
1402
1x
        FROM question_priority_scores qps
1403
1x
        JOIN questions q ON qps.question_id = q.id
1404
1x
        WHERE qps.priority_score > 200
1405
1x
        ORDER BY qps.priority_score DESC
1406
1x
        LIMIT $1
1407
1x
    `
1408
1x

1409
1x
    rows, err := s.db.QueryContext(ctx, query, limit)
1410
1x
    if err != nil {
1411
        return nil, contextutils.WrapErrorf(contextutils.ErrDatabaseQuery, "failed to get high priority questions: %w", err)
1412
    }
1413
1x
    defer func() {
1414
1x
        if err := rows.Close(); err != nil {
1415
            s.logger.Warn(ctx, "Failed to close rows", map[string]interface{}{"error": err.Error()})
1416
        }
1417
    }()
1418

1419
1x
    var questions []map[string]interface{}
1420
1x
    for rows.Next() {
1421
3x
        var questionType, level, topic sql.NullString
1422
3x
        var priorityScore float64
1423
3x

1424
3x
        err = rows.Scan(&questionType, &level, &topic, &priorityScore)
1425
3x
        if err != nil {
1426
            continue
1427
        }
1428

1429
3x
        question := map[string]interface{}{
1430
3x
            "question_type":  questionType.String,
1431
3x
            "level":          level.String,
1432
3x
            "topic":          topic.String,
1433
3x
            "priority_score": priorityScore,
1434
3x
        }
1435
3x
        questions = append(questions, question)
1436
    }
1437

1438
1x
    span.SetAttributes(attribute.Int("questions_count", len(questions)))
1439
1x
    return questions, nil
1440
}
1441

1442
// GetWeakAreasByTopic returns weak areas by topic
1443
1x
func (s *LearningService) GetWeakAreasByTopic(ctx context.Context, limit int) (result0 []map[string]interface{}, err error) {
1444
1x
    ctx, span := observability.TraceLearningFunction(ctx, "get_weak_areas_by_topic",
1445
1x
        attribute.Int("limit", limit),
1446
1x
    )
1447
1x
    defer func() {
1448
1x
        if err != nil {
1449
            span.RecordError(err, trace.WithStackTrace(true))
1450
            span.SetStatus(codes.Error, err.Error())
1451
        }
1452
1x
        span.End()
1453
    }()
1454

1455
1x
    query := `
1456
1x
        SELECT
1457
1x
            topic,
1458
1x
            SUM(total_attempts) as total_attempts,
1459
1x
            SUM(correct_attempts) as correct_attempts
1460
1x
        FROM performance_metrics
1461
1x
        WHERE total_attempts > 0
1462
1x
        GROUP BY topic
1463
1x
        ORDER BY (SUM(correct_attempts)::float / SUM(total_attempts)) ASC
1464
1x
        LIMIT $1
1465
1x
    `
1466
1x

1467
1x
    rows, err := s.db.QueryContext(ctx, query, limit)
1468
1x
    if err != nil {
1469
        return nil, contextutils.WrapErrorf(contextutils.ErrDatabaseQuery, "failed to get weak areas: %w", err)
1470
    }
1471
1x
    defer func() {
1472
1x
        if err := rows.Close(); err != nil {
1473
            s.logger.Warn(ctx, "Failed to close rows", map[string]interface{}{"error": err.Error()})
1474
        }
1475
    }()
1476

1477
1x
    var weakAreas []map[string]interface{}
1478
1x
    for rows.Next() {
1479
1x
        var topic sql.NullString
1480
1x
        var totalAttempts, correctAttempts int
1481
1x

1482
1x
        err = rows.Scan(&topic, &totalAttempts, &correctAttempts)
1483
1x
        if err != nil {
1484
            continue
1485
        }
1486

1487
1x
        area := map[string]interface{}{
1488
1x
            "topic":            topic.String,
1489
1x
            "total_attempts":   totalAttempts,
1490
1x
            "correct_attempts": correctAttempts,
1491
1x
        }
1492
1x
        weakAreas = append(weakAreas, area)
1493
    }
1494

1495
1x
    span.SetAttributes(attribute.Int("weak_areas_count", len(weakAreas)))
1496
1x
    return weakAreas, nil
1497
}
1498

1499
// GetLearningPreferencesUsage returns learning preferences usage statistics
1500
1x
func (s *LearningService) GetLearningPreferencesUsage(ctx context.Context) (result0 map[string]interface{}, err error) {
1501
1x
    ctx, span := observability.TraceLearningFunction(ctx, "get_learning_preferences_usage")
1502
1x
    defer func() {
1503
1x
        if err != nil {
1504
            span.RecordError(err, trace.WithStackTrace(true))
1505
            span.SetStatus(codes.Error, err.Error())
1506
        }
1507
1x
        span.End()
1508
    }()
1509

1510
1x
    query := `
1511
1x
        SELECT
1512
1x
            COUNT(*) as total_users,
1513
1x
            AVG(focus_on_weak_areas::int) as avg_focus_on_weak_areas,
1514
1x
            AVG(fresh_question_ratio) as avg_fresh_question_ratio,
1515
1x
            AVG(weak_area_boost) as avg_weak_area_boost,
1516
1x
            AVG(known_question_penalty) as avg_known_question_penalty
1517
1x
        FROM user_learning_preferences
1518
1x
    `
1519
1x

1520
1x
    var totalUsers int
1521
1x
    var avgFocusOnWeakAreas, avgFreshQuestionRatio, avgWeakAreaBoost, avgKnownQuestionPenalty sql.NullFloat64
1522
1x

1523
1x
    err = s.db.QueryRowContext(ctx, query).Scan(
1524
1x
        &totalUsers,
1525
1x
        &avgFocusOnWeakAreas,
1526
1x
        &avgFreshQuestionRatio,
1527
1x
        &avgWeakAreaBoost,
1528
1x
        &avgKnownQuestionPenalty,
1529
1x
    )
1530
1x
    if err != nil {
1531
        return nil, contextutils.WrapErrorf(contextutils.ErrDatabaseQuery, "failed to get learning preferences usage: %w", err)
1532
    }
1533

1534
1x
    result := map[string]interface{}{
1535
1x
        "total_users":          0,
1536
1x
        "focusOnWeakAreas":     false,
1537
1x
        "freshQuestionRatio":   0.3,
1538
1x
        "weakAreaBoost":        2.0,
1539
1x
        "knownQuestionPenalty": 0.1,
1540
1x
    }
1541
1x

1542
1x
    if totalUsers > 0 {
1543
1x
        result["total_users"] = totalUsers
1544
1x
        if avgFocusOnWeakAreas.Valid {
1545
1x
            result["focusOnWeakAreas"] = avgFocusOnWeakAreas.Float64 > 0.5
1546
1x
        }
1547
1x
        if avgFreshQuestionRatio.Valid {
1548
1x
            result["freshQuestionRatio"] = avgFreshQuestionRatio.Float64
1549
1x
        }
1550
1x
        if avgWeakAreaBoost.Valid {
1551
1x
            result["weakAreaBoost"] = avgWeakAreaBoost.Float64
1552
1x
        }
1553
1x
        if avgKnownQuestionPenalty.Valid {
1554
1x
            result["knownQuestionPenalty"] = avgKnownQuestionPenalty.Float64
1555
1x
        }
1556
    }
1557

1558
1x
    span.SetAttributes(
1559
1x
        attribute.Int("total_users", result["total_users"].(int)),
1560
1x
        attribute.Bool("focus_on_weak_areas", result["focusOnWeakAreas"].(bool)),
1561
1x
        attribute.Float64("fresh_question_ratio", result["freshQuestionRatio"].(float64)),
1562
1x
        attribute.Float64("weak_area_boost", result["weakAreaBoost"].(float64)),
1563
1x
        attribute.Float64("known_question_penalty", result["knownQuestionPenalty"].(float64)),
1564
1x
    )
1565
1x

1566
1x
    return result, nil
1567
}
1568

1569
// GetQuestionTypeGaps returns gaps in question types
1570
1x
func (s *LearningService) GetQuestionTypeGaps(ctx context.Context) (result0 []map[string]interface{}, err error) {
1571
1x
    ctx, span := observability.TraceLearningFunction(ctx, "get_question_type_gaps")
1572
1x
    defer func() {
1573
1x
        if err != nil {
1574
            span.RecordError(err, trace.WithStackTrace(true))
1575
            span.SetStatus(codes.Error, err.Error())
1576
        }
1577
1x
        span.End()
1578
    }()
1579

1580
1x
    query := `
1581
1x
        SELECT
1582
1x
            q.type as question_type,
1583
1x
            q.level,
1584
1x
            COUNT(q.id) as available,
1585
1x
            COUNT(qps.question_id) as with_priority_scores
1586
1x
        FROM questions q
1587
1x
        LEFT JOIN question_priority_scores qps ON q.id = qps.question_id
1588
1x
        GROUP BY q.type, q.level
1589
1x
        HAVING COUNT(qps.question_id) < COUNT(q.id) * 0.8
1590
1x
        ORDER BY (COUNT(qps.question_id)::float / COUNT(q.id)) ASC
1591
1x
    `
1592
1x

1593
1x
    rows, err := s.db.QueryContext(ctx, query)
1594
1x
    if err != nil {
1595
        span.SetAttributes(attribute.String("error.type", "database_query_failed"), attribute.String("error", err.Error()))
1596
        return nil, contextutils.WrapErrorf(contextutils.ErrDatabaseQuery, "failed to get question type gaps: %w", err)
1597
    }
1598
1x
    defer func() {
1599
1x
        if err := rows.Close(); err != nil {
1600
            s.logger.Warn(ctx, "Failed to close rows in GetQuestionTypeGaps", map[string]interface{}{"error": err.Error()})
1601
        }
1602
    }()
1603

1604
1x
    var gaps []map[string]interface{}
1605
1x
    var scanErrors int
1606
1x

1607
1x
    for rows.Next() {
1608
3x
        var questionType, level sql.NullString
1609
3x
        var available, withPriorityScores int
1610
3x

1611
3x
        err = rows.Scan(&questionType, &level, &available, &withPriorityScores)
1612
3x
        if err != nil {
1613
            scanErrors++
1614
            span.SetAttributes(attribute.String("error.type", "row_scan_failed"), attribute.String("error", err.Error()))
1615
            continue
1616
        }
1617

1618
3x
        gap := map[string]interface{}{
1619
3x
            "question_type": questionType.String,
1620
3x
            "level":         level.String,
1621
3x
            "available":     available,
1622
3x
            "demand":        available - withPriorityScores,
1623
3x
        }
1624
3x
        gaps = append(gaps, gap)
1625
    }
1626

1627
1x
    if err := rows.Err(); err != nil {
1628
        span.SetAttributes(attribute.String("error.type", "rows_iteration_failed"), attribute.String("error", err.Error()))
1629
        return nil, contextutils.WrapErrorf(contextutils.ErrDatabaseQuery, "error during rows iteration: %w", err)
1630
    }
1631

1632
1x
    span.SetAttributes(
1633
1x
        attribute.Int("gaps_count", len(gaps)),
1634
1x
        attribute.Int("scan_errors", scanErrors),
1635
1x
    )
1636
1x
    return gaps, nil
1637
}
1638

1639
// GetGenerationSuggestions returns suggestions for question generation
1640
1x
func (s *LearningService) GetGenerationSuggestions(ctx context.Context) (result0 []map[string]interface{}, err error) {
1641
1x
    ctx, span := observability.TraceLearningFunction(ctx, "get_generation_suggestions")
1642
1x
    defer func() {
1643
1x
        if err != nil {
1644
            span.RecordError(err, trace.WithStackTrace(true))
1645
            span.SetStatus(codes.Error, err.Error())
1646
        }
1647
1x
        span.End()
1648
    }()
1649

1650
1x
    query := `
1651
1x
        SELECT
1652
1x
            q.type as question_type,
1653
1x
            q.level,
1654
1x
            q.language,
1655
1x
            COUNT(q.id) as available,
1656
1x
            COUNT(CASE WHEN qps.priority_score > 100 THEN 1 END) as high_priority,
1657
1x
            AVG(qps.priority_score) as avg_priority
1658
1x
        FROM questions q
1659
1x
        LEFT JOIN question_priority_scores qps ON q.id = qps.question_id
1660
1x
        GROUP BY q.type, q.level, q.language
1661
1x
        HAVING COUNT(q.id) < 50 OR COUNT(CASE WHEN qps.priority_score > 100 THEN 1 END) < 10
1662
1x
        ORDER BY COUNT(q.id) ASC, AVG(qps.priority_score) DESC
1663
1x
    `
1664
1x

1665
1x
    rows, err := s.db.QueryContext(ctx, query)
1666
1x
    if err != nil {
1667
        span.SetAttributes(attribute.String("error.type", "database_query_failed"), attribute.String("error", err.Error()))
1668
        return nil, contextutils.WrapErrorf(contextutils.ErrDatabaseQuery, "failed to get generation suggestions: %w", err)
1669
    }
1670
1x
    defer func() {
1671
1x
        if err := rows.Close(); err != nil {
1672
            s.logger.Warn(ctx, "Failed to close rows in GetGenerationSuggestions", map[string]interface{}{"error": err.Error()})
1673
        }
1674
    }()
1675

1676
1x
    var suggestions []map[string]interface{}
1677
1x
    var scanErrors int
1678
1x

1679
1x
    for rows.Next() {
1680
1x
        var questionType, level, language sql.NullString
1681
1x
        var available, highPriority int
1682
1x
        var avgPriority sql.NullFloat64
1683
1x

1684
1x
        err = rows.Scan(&questionType, &level, &language, &available, &highPriority, &avgPriority)
1685
1x
        if err != nil {
1686
            scanErrors++
1687
            span.SetAttributes(attribute.String("error.type", "row_scan_failed"), attribute.String("error", err.Error()))
1688
            continue
1689
        }
1690

1691
1x
        suggestion := map[string]interface{}{
1692
1x
            "question_type":  questionType.String,
1693
1x
            "level":          level.String,
1694
1x
            "language":       language.String,
1695
1x
            "available":      available,
1696
1x
            "high_priority":  highPriority,
1697
1x
            "avg_priority":   0.0,
1698
1x
            "priority_score": 0.0,
1699
1x
        }
1700
1x

1701
1x
        if avgPriority.Valid {
1702
            suggestion["avg_priority"] = avgPriority.Float64
1703
            suggestion["priority_score"] = avgPriority.Float64
1704
        }
1705

1706
1x
        suggestions = append(suggestions, suggestion)
1707
    }
1708

1709
1x
    if err := rows.Err(); err != nil {
1710
        span.SetAttributes(attribute.String("error.type", "rows_iteration_failed"), attribute.String("error", err.Error()))
1711
        return nil, contextutils.WrapErrorf(contextutils.ErrDatabaseQuery, "error during rows iteration: %w", err)
1712
    }
1713

1714
1x
    span.SetAttributes(
1715
1x
        attribute.Int("suggestions_count", len(suggestions)),
1716
1x
        attribute.Int("scan_errors", scanErrors),
1717
1x
    )
1718
1x
    return suggestions, nil
1719
}
1720

1721
// GetPrioritySystemPerformance returns performance metrics for the priority system
1722
1x
func (s *LearningService) GetPrioritySystemPerformance(ctx context.Context) (result0 map[string]interface{}, err error) {
1723
1x
    ctx, span := observability.TraceLearningFunction(ctx, "get_priority_system_performance")
1724
1x
    defer func() {
1725
1x
        if err != nil {
1726
            span.RecordError(err, trace.WithStackTrace(true))
1727
            span.SetStatus(codes.Error, err.Error())
1728
        }
1729
1x
        span.End()
1730
    }()
1731

1732
    // This is a simplified implementation - in a real system, this would track actual performance metrics
1733
1x
    query := `
1734
1x
        SELECT
1735
1x
            COUNT(*) as total_calculations,
1736
1x
            AVG(priority_score) as avg_score,
1737
1x
            MAX(last_calculated_at) as last_calculation
1738
1x
        FROM question_priority_scores
1739
1x
        WHERE last_calculated_at > NOW() - INTERVAL '1 hour'
1740
1x
    `
1741
1x

1742
1x
    var totalCalculations int
1743
1x
    var avgScore sql.NullFloat64
1744
1x
    var lastCalculation sql.NullTime
1745
1x

1746
1x
    err = s.db.QueryRowContext(ctx, query).Scan(&totalCalculations, &avgScore, &lastCalculation)
1747
1x
    if err != nil {
1748
        return nil, contextutils.WrapErrorf(contextutils.ErrDatabaseQuery, "failed to get priority system performance: %w", err)
1749
    }
1750

1751
1x
    result := map[string]interface{}{
1752
1x
        "calculationsPerSecond": float64(totalCalculations) / 3600.0, // Per hour converted to per second
1753
1x
        "avgCalculationTime":    0.0,                                 // Would need to track actual calculation times
1754
1x
        "avgQueryTime":          0.0,                                 // Would need to track actual query times
1755
1x
        "memoryUsage":           0.0,                                 // Would need to track actual memory usage
1756
1x
        "avgScore":              0.0,                                 // Default value
1757
1x
    }
1758
1x

1759
1x
    if avgScore.Valid {
1760
        result["avgScore"] = avgScore.Float64
1761
    }
1762

1763
1x
    if lastCalculation.Valid {
1764
        result["lastCalculation"] = lastCalculation.Time.Format(time.RFC3339)
1765
    }
1766

1767
1x
    span.SetAttributes(
1768
1x
        attribute.Float64("calculations_per_second", result["calculationsPerSecond"].(float64)),
1769
1x
        attribute.Float64("avg_score", result["avgScore"].(float64)),
1770
1x
        attribute.Int("total_calculations", totalCalculations),
1771
1x
    )
1772
1x

1773
1x
    return result, nil
1774
}
1775

1776
// GetBackgroundJobsStatus returns the status of background jobs
1777
1x
func (s *LearningService) GetBackgroundJobsStatus(ctx context.Context) (result0 map[string]interface{}, err error) {
1778
1x
    ctx, span := observability.TraceLearningFunction(ctx, "get_background_jobs_status")
1779
1x
    defer func() {
1780
1x
        if err != nil {
1781
            span.RecordError(err, trace.WithStackTrace(true))
1782
            span.SetStatus(codes.Error, err.Error())
1783
        }
1784
1x
        span.End()
1785
    }()
1786

1787
    // This is a simplified implementation - in a real system, this would track actual background job status
1788
1x
    query := `
1789
1x
        SELECT
1790
1x
            COUNT(*) as total_updates,
1791
1x
            MAX(updated_at) as last_update
1792
1x
        FROM question_priority_scores
1793
1x
        WHERE updated_at > NOW() - INTERVAL '1 minute'
1794
1x
    `
1795
1x

1796
1x
    var totalUpdates int
1797
1x
    var lastUpdate sql.NullTime
1798
1x

1799
1x
    err = s.db.QueryRowContext(ctx, query).Scan(&totalUpdates, &lastUpdate)
1800
1x
    if err != nil {
1801
        return nil, contextutils.WrapError(err, "failed to get background jobs status")
1802
    }
1803

1804
1x
    result := map[string]interface{}{
1805
1x
        "priorityUpdates": totalUpdates,
1806
1x
        "lastUpdate":      "N/A",
1807
1x
        "queueSize":       0, // Would need to track actual queue size
1808
1x
        "status":          "healthy",
1809
1x
    }
1810
1x

1811
1x
    if lastUpdate.Valid {
1812
        result["lastUpdate"] = lastUpdate.Time.Format(time.RFC3339)
1813
    }
1814

1815
1x
    if totalUpdates == 0 {
1816
1x
        result["status"] = "idle"
1817
1x
    }
1818

1819
1x
    span.SetAttributes(
1820
1x
        attribute.Int("priority_updates", totalUpdates),
1821
1x
        attribute.String("status", result["status"].(string)),
1822
1x
        attribute.Int("queue_size", result["queueSize"].(int)),
1823
1x
    )
1824
1x

1825
1x
    return result, nil
1826
}
1827

1828
// GetUserPriorityScoreDistribution returns priority score distribution for a specific user
1829
4x
func (s *LearningService) GetUserPriorityScoreDistribution(ctx context.Context, userID int) (result0 map[string]interface{}, err error) {
1830
4x
    ctx, span := observability.TraceLearningFunction(ctx, "get_user_priority_score_distribution",
1831
4x
        observability.AttributeUserID(userID),
1832
4x
    )
1833
4x
    defer func() {
1834
4x
        if err != nil {
1835
            span.RecordError(err, trace.WithStackTrace(true))
1836
            span.SetStatus(codes.Error, err.Error())
1837
        }
1838
4x
        span.End()
1839
    }()
1840

1841
4x
    query := `
1842
4x
        SELECT
1843
4x
            COUNT(CASE WHEN priority_score > 200 THEN 1 END) as high,
1844
4x
            COUNT(CASE WHEN priority_score BETWEEN 100 AND 200 THEN 1 END) as medium,
1845
4x
            COUNT(CASE WHEN priority_score < 100 THEN 1 END) as low,
1846
4x
            AVG(priority_score) as average
1847
4x
        FROM question_priority_scores
1848
4x
        WHERE user_id = $1 AND priority_score > 0
1849
4x
    `
1850
4x

1851
4x
    var high, medium, low int
1852
4x
    var average sql.NullFloat64
1853
4x

1854
4x
    err = s.db.QueryRowContext(ctx, query, userID).Scan(&high, &medium, &low, &average)
1855
4x
    if err != nil {
1856
        return nil, contextutils.WrapError(err, "failed to get user priority score distribution")
1857
    }
1858

1859
4x
    result := map[string]interface{}{
1860
4x
        "high":    high,
1861
4x
        "medium":  medium,
1862
4x
        "low":     low,
1863
4x
        "average": 0.0,
1864
4x
    }
1865
4x

1866
4x
    if average.Valid {
1867
1x
        result["average"] = average.Float64
1868
1x
    }
1869

1870
4x
    span.SetAttributes(
1871
4x
        attribute.Int("high_count", high),
1872
4x
        attribute.Int("medium_count", medium),
1873
4x
        attribute.Int("low_count", low),
1874
4x
        attribute.Float64("average_score", result["average"].(float64)),
1875
4x
    )
1876
4x

1877
4x
    return result, nil
1878
}
1879

1880
// GetUserHighPriorityQuestions returns the highest priority questions for a specific user
1881
5x
func (s *LearningService) GetUserHighPriorityQuestions(ctx context.Context, userID, limit int) (result0 []map[string]interface{}, err error) {
1882
5x
    ctx, span := observability.TraceLearningFunction(ctx, "get_user_high_priority_questions",
1883
5x
        observability.AttributeUserID(userID),
1884
5x
        attribute.Int("limit", limit),
1885
5x
    )
1886
5x
    defer func() {
1887
5x
        if err != nil {
1888
            span.RecordError(err, trace.WithStackTrace(true))
1889
            span.SetStatus(codes.Error, err.Error())
1890
        }
1891
5x
        span.End()
1892
    }()
1893

1894
5x
    query := `
1895
5x
        SELECT
1896
5x
            q.type as question_type,
1897
5x
            q.level,
1898
5x
            q.topic_category as topic,
1899
5x
            qps.priority_score
1900
5x
        FROM question_priority_scores qps
1901
5x
        JOIN questions q ON qps.question_id = q.id
1902
5x
        WHERE qps.user_id = $1 AND qps.priority_score > 200
1903
5x
        ORDER BY qps.priority_score DESC
1904
5x
        LIMIT $2
1905
5x
    `
1906
5x

1907
5x
    rows, err := s.db.QueryContext(ctx, query, userID, limit)
1908
5x
    if err != nil {
1909
        return nil, contextutils.WrapError(err, "failed to get user high priority questions")
1910
    }
1911
5x
    defer func() {
1912
5x
        if err := rows.Close(); err != nil {
1913
            s.logger.Warn(ctx, "Failed to close rows", map[string]interface{}{"error": err.Error()})
1914
        }
1915
    }()
1916

1917
5x
    var questions []map[string]interface{}
1918
5x
    for rows.Next() {
1919
9x
        var questionType, level, topic sql.NullString
1920
9x
        var priorityScore float64
1921
9x

1922
9x
        err = rows.Scan(&questionType, &level, &topic, &priorityScore)
1923
9x
        if err != nil {
1924
            continue
1925
        }
1926

1927
9x
        question := map[string]interface{}{
1928
9x
            "question_type":  questionType.String,
1929
9x
            "level":          level.String,
1930
9x
            "topic":          topic.String,
1931
9x
            "priority_score": priorityScore,
1932
9x
        }
1933
9x
        questions = append(questions, question)
1934
    }
1935

1936
5x
    span.SetAttributes(attribute.Int("questions_count", len(questions)))
1937
5x
    return questions, nil
1938
}
1939

1940
// GetUserWeakAreas returns weak areas for a specific user
1941
7x
func (s *LearningService) GetUserWeakAreas(ctx context.Context, userID, limit int) (result0 []map[string]interface{}, err error) {
1942
7x
    ctx, span := observability.TraceLearningFunction(ctx, "get_user_weak_areas",
1943
7x
        observability.AttributeUserID(userID),
1944
7x
        attribute.Int("limit", limit),
1945
7x
    )
1946
7x
    defer func() {
1947
7x
        if err != nil {
1948
1x
            span.RecordError(err, trace.WithStackTrace(true))
1949
1x
            span.SetStatus(codes.Error, err.Error())
1950
1x
        }
1951
7x
        span.End()
1952
    }()
1953

1954
7x
    query := `
1955
7x
        SELECT
1956
7x
            topic,
1957
7x
            total_attempts,
1958
7x
            correct_attempts
1959
7x
        FROM performance_metrics
1960
7x
        WHERE user_id = $1 AND total_attempts > 0
1961
7x
        ORDER BY (correct_attempts::float / total_attempts) ASC
1962
7x
        LIMIT $2
1963
7x
    `
1964
7x

1965
7x
    rows, err := s.db.QueryContext(ctx, query, userID, limit)
1966
7x
    if err != nil {
1967
1x
        return nil, contextutils.WrapError(err, "failed to get user weak areas")
1968
1x
    }
1969
6x
    defer func() {
1970
6x
        if err := rows.Close(); err != nil {
1971
            s.logger.Warn(ctx, "Failed to close rows", map[string]interface{}{"error": err.Error()})
1972
        }
1973
    }()
1974

1975
6x
    var weakAreas []map[string]interface{}
1976
6x
    for rows.Next() {
1977
8x
        var topic sql.NullString
1978
8x
        var totalAttempts, correctAttempts int
1979
8x

1980
8x
        err = rows.Scan(&topic, &totalAttempts, &correctAttempts)
1981
8x
        if err != nil {
1982
            continue
1983
        }
1984

1985
8x
        area := map[string]interface{}{
1986
8x
            "topic":            topic.String,
1987
8x
            "total_attempts":   totalAttempts,
1988
8x
            "correct_attempts": correctAttempts,
1989
8x
        }
1990
8x
        weakAreas = append(weakAreas, area)
1991
    }
1992

1993
6x
    span.SetAttributes(attribute.Int("weak_areas_count", len(weakAreas)))
1994
6x
    return weakAreas, nil
1995
}
1996

1997
// Priority generation methods moved to worker
1998

1999
// GetHighPriorityTopics returns topics with high average priority scores for a user
2000
1x
func (s *LearningService) GetHighPriorityTopics(ctx context.Context, userID int) (result0 []string, err error) {
2001
1x
    ctx, span := observability.TraceLearningFunction(ctx, "get_high_priority_topics",
2002
1x
        observability.AttributeUserID(userID),
2003
1x
    )
2004
1x
    defer func() {
2005
1x
        if err != nil {
2006
            span.RecordError(err, trace.WithStackTrace(true))
2007
            span.SetStatus(codes.Error, err.Error())
2008
        }
2009
1x
        span.End()
2010
    }()
2011

2012
1x
    query := `
2013
1x
        SELECT q.topic_category, AVG(qps.priority_score) as avg_score
2014
1x
        FROM questions q
2015
1x
        JOIN user_questions uq ON q.id = uq.question_id
2016
1x
        JOIN question_priority_scores qps ON q.id = qps.question_id AND qps.user_id = $1
2017
1x
        WHERE uq.user_id = $1
2018
1x
        AND q.topic_category IS NOT NULL
2019
1x
        AND q.topic_category != ''
2020
1x
        GROUP BY q.topic_category
2021
1x
        HAVING AVG(qps.priority_score) >= 150.0
2022
1x
        ORDER BY avg_score DESC
2023
1x
        LIMIT 5
2024
1x
    `
2025
1x

2026
1x
    rows, err := s.db.QueryContext(ctx, query, userID)
2027
1x
    if err != nil {
2028
        return nil, contextutils.WrapError(err, "failed to get high priority topics")
2029
    }
2030
1x
    defer func() {
2031
1x
        if err := rows.Close(); err != nil {
2032
            s.logger.Warn(ctx, "Failed to close rows", map[string]interface{}{"error": err.Error()})
2033
        }
2034
    }()
2035

2036
1x
    var topics []string
2037
1x
    for rows.Next() {
2038
        var topic string
2039
        var avgScore float64
2040
        if err := rows.Scan(&topic, &avgScore); err != nil {
2041
            continue
2042
        }
2043
        topics = append(topics, topic)
2044
    }
2045

2046
1x
    span.SetAttributes(attribute.Int("topics_count", len(topics)))
2047
1x
    // Ensure we always return a slice, not nil
2048
1x
    if topics == nil {
2049
1x
        topics = []string{}
2050
1x
    }
2051
1x
    return topics, nil
2052
}
2053

2054
// GetGapAnalysis identifies areas with poor user performance (knowledge gaps)
2055
1x
func (s *LearningService) GetGapAnalysis(ctx context.Context, userID int) (result0 map[string]interface{}, err error) {
2056
1x
    ctx, span := observability.TraceLearningFunction(ctx, "get_gap_analysis",
2057
1x
        observability.AttributeUserID(userID),
2058
1x
    )
2059
1x
    defer func() {
2060
1x
        if err != nil {
2061
            span.RecordError(err, trace.WithStackTrace(true))
2062
            span.SetStatus(codes.Error, err.Error())
2063
        }
2064
1x
        span.End()
2065
    }()
2066

2067
    // Query to find areas where user has poor performance (low accuracy)
2068
1x
    query := `
2069
1x
        SELECT
2070
1x
            pm.topic,
2071
1x
            COUNT(*) as total_questions,
2072
1x
            ROUND((pm.correct_attempts * 100.0 / pm.total_attempts), 2) as accuracy_percentage
2073
1x
        FROM performance_metrics pm
2074
1x
        WHERE pm.user_id = $1
2075
1x
        AND pm.total_attempts >= 3
2076
1x
        AND (pm.correct_attempts * 100.0 / pm.total_attempts) < 70.0
2077
1x
        GROUP BY pm.topic, pm.correct_attempts, pm.total_attempts
2078
1x
        ORDER BY accuracy_percentage ASC
2079
1x
        LIMIT 10
2080
1x
    `
2081
1x

2082
1x
    rows, err := s.db.QueryContext(ctx, query, userID)
2083
1x
    if err != nil {
2084
        return nil, contextutils.WrapError(err, "failed to get gap analysis")
2085
    }
2086
1x
    defer func() {
2087
1x
        if err := rows.Close(); err != nil {
2088
            s.logger.Warn(ctx, "Failed to close rows", map[string]interface{}{"error": err.Error()})
2089
        }
2090
    }()
2091

2092
1x
    gaps := make(map[string]interface{})
2093
1x
    for rows.Next() {
2094
        var topic string
2095
        var totalQuestions int
2096
        var accuracyPercentage sql.NullFloat64
2097

2098
        if err := rows.Scan(&topic, &totalQuestions, &accuracyPercentage); err != nil {
2099
            continue
2100
        }
2101

2102
        gapInfo := map[string]interface{}{
2103
            "topic":               topic,
2104
            "total_questions":     totalQuestions,
2105
            "accuracy_percentage": 0.0,
2106
        }
2107

2108
        if accuracyPercentage.Valid {
2109
            gapInfo["accuracy_percentage"] = accuracyPercentage.Float64
2110
        }
2111

2112
        gaps[topic] = gapInfo
2113
    }
2114

2115
1x
    span.SetAttributes(attribute.Int("gaps_count", len(gaps)))
2116
1x
    return gaps, nil
2117
}
2118

2119
// GetPriorityDistribution returns the distribution of priority scores by topic for a user
2120
1x
func (s *LearningService) GetPriorityDistribution(ctx context.Context, userID int) (result0 map[string]int, err error) {
2121
1x
    ctx, span := observability.TraceLearningFunction(ctx, "get_priority_distribution",
2122
1x
        observability.AttributeUserID(userID),
2123
1x
    )
2124
1x
    defer func() {
2125
1x
        if err != nil {
2126
            span.RecordError(err, trace.WithStackTrace(true))
2127
            span.SetStatus(codes.Error, err.Error())
2128
        }
2129
1x
        span.End()
2130
    }()
2131

2132
    // Query to get priority score distribution by topic
2133
1x
    query := `
2134
1x
        SELECT q.topic_category, COUNT(*) as question_count
2135
1x
        FROM questions q
2136
1x
        JOIN user_questions uq ON q.id = uq.question_id
2137
1x
        JOIN question_priority_scores qps ON q.id = qps.question_id AND qps.user_id = $1
2138
1x
        WHERE uq.user_id = $1
2139
1x
        AND q.topic_category IS NOT NULL
2140
1x
        AND q.topic_category != ''
2141
1x
        GROUP BY q.topic_category
2142
1x
        ORDER BY question_count DESC
2143
1x
    `
2144
1x

2145
1x
    rows, err := s.db.QueryContext(ctx, query, userID)
2146
1x
    if err != nil {
2147
        return nil, contextutils.WrapError(err, "failed to get priority distribution")
2148
    }
2149
1x
    defer func() {
2150
1x
        if err := rows.Close(); err != nil {
2151
            s.logger.Warn(ctx, "Failed to close rows", map[string]interface{}{"error": err.Error()})
2152
        }
2153
    }()
2154

2155
1x
    distribution := make(map[string]int)
2156
1x
    for rows.Next() {
2157
        var topic string
2158
        var count int
2159
        if err := rows.Scan(&topic, &count); err != nil {
2160
            continue
2161
        }
2162
        distribution[topic] = count
2163
    }
2164

2165
1x
    span.SetAttributes(attribute.Int("topics_count", len(distribution)))
2166
1x
    return distribution, nil
2167
}
2168

2169
// GetUserQuestionConfidenceLevel retrieves the confidence level for a specific question and user
2170
func (s *LearningService) GetUserQuestionConfidenceLevel(ctx context.Context, userID, questionID int) (result0 *int, err error) {
2171
    ctx, span := observability.TraceLearningFunction(ctx, "get_user_question_confidence_level",
2172
        observability.AttributeUserID(userID),
2173
        observability.AttributeQuestionID(questionID),
2174
    )
2175
    defer func() {
2176
        if err != nil {
2177
            span.RecordError(err, trace.WithStackTrace(true))
2178
            span.SetStatus(codes.Error, err.Error())
2179
        }
2180
        span.End()
2181
    }()
2182

2183
    query := `
2184
        SELECT confidence_level
2185
        FROM user_question_metadata
2186
        WHERE user_id = $1 AND question_id = $2
2187
    `
2188

2189
    var confidenceLevel sql.NullInt32
2190
    err = s.db.QueryRowContext(ctx, query, userID, questionID).Scan(&confidenceLevel)
2191
    if err != nil {
2192
        if err == sql.ErrNoRows {
2193
            // No confidence level recorded for this user-question pair
2194
            return nil, nil
2195
        }
2196
        return nil, contextutils.WrapError(err, "failed to get user question confidence level")
2197
    }
2198

2199
    if confidenceLevel.Valid {
2200
        level := int(confidenceLevel.Int32)
2201
        return &level, nil
2202
    }
2203

2204
    return nil, nil
2205
}
2206


			
quizapp internal services worker_service.go
81.8%
Statements
260/318
1
package services
2

3
import (
4
    "bytes"
5
    "context"
6
    "encoding/json"
7
    "fmt"
8
    "io"
9
    "net/http"
10
    "regexp"
11
    "strings"
12
    "time"
13

14
    "quizapp/internal/config"
15
    "quizapp/internal/observability"
16
    contextutils "quizapp/internal/utils"
17

18
    "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
19
    "go.opentelemetry.io/otel/attribute"
20
    "go.opentelemetry.io/otel/trace"
21
)
22

23
// uuidRegex matches standard UUID format (8-4-4-4-12 hex digits)
24
var uuidRegex = regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`)
25

26
// Linear API constants
27
const (
28
    // LinearAPIEndpoint is the base URL for Linear's GraphQL API
29
    LinearAPIEndpoint = "https://api.linear.app/graphql"
30
    // LinearHTTPTimeout is the timeout for Linear API requests
31
    LinearHTTPTimeout = 30 * time.Second
32
)
33

34
// LinearService handles Linear API integration
35
type LinearService struct {
36
    config     *config.Config
37
    httpClient *http.Client
38
    logger     *observability.Logger
39
    apiURL     string // Allow overriding API endpoint for testing
40
}
41

42
// LinearIssueResponse represents the response from Linear API
43
type LinearIssueResponse struct {
44
    Data struct {
45
        IssueCreate struct {
46
            Success bool `json:"success"`
47
            Issue   struct {
48
                ID    string `json:"id"`
49
                Title string `json:"title"`
50
                URL   string `json:"url"`
51
            } `json:"issue"`
52
        } `json:"issueCreate"`
53
    } `json:"data"`
54
    Errors []struct {
55
        Message    string                 `json:"message"`
56
        Extensions map[string]interface{} `json:"extensions,omitempty"`
57
        Path       []interface{}          `json:"path,omitempty"`
58
    } `json:"errors,omitempty"`
59
}
60

61
// LinearIssueResult represents the result of creating a Linear issue
62
type LinearIssueResult struct {
63
    IssueID  string `json:"issue_id"`
64
    IssueURL string `json:"issue_url"`
65
    Title    string `json:"title"`
66
}
67

68
// NewLinearService creates a new Linear service instance
69
2x
func NewLinearService(cfg *config.Config, logger *observability.Logger) *LinearService {
70
2x
    return &LinearService{
71
2x
        config: cfg,
72
2x
        httpClient: &http.Client{
73
2x
            Timeout: LinearHTTPTimeout,
74
2x
            Transport: otelhttp.NewTransport(http.DefaultTransport,
75
2x
                otelhttp.WithSpanOptions(trace.WithSpanKind(trace.SpanKindClient)),
76
2x
            ),
77
2x
        },
78
2x
        logger: logger,
79
2x
        apiURL: LinearAPIEndpoint,
80
2x
    }
81
2x
}
82

83
// NewLinearServiceWithURL creates a new LinearService instance with a custom API URL (for testing)
84
34x
func NewLinearServiceWithURL(cfg *config.Config, logger *observability.Logger, apiURL string) *LinearService {
85
34x
    return &LinearService{
86
34x
        config: cfg,
87
34x
        httpClient: &http.Client{
88
34x
            Timeout: LinearHTTPTimeout,
89
34x
            Transport: otelhttp.NewTransport(http.DefaultTransport,
90
34x
                otelhttp.WithSpanOptions(trace.WithSpanKind(trace.SpanKindClient)),
91
34x
            ),
92
34x
        },
93
34x
        logger: logger,
94
34x
        apiURL: apiURL,
95
34x
    }
96
34x
}
97

98
// getTeamIDByName looks up a team ID by name, or returns the ID if it's already a UUID
99
16x
func (s *LinearService) getTeamIDByName(ctx context.Context, teamIdentifier string) (string, error) {
100
16x
    // If it looks like a UUID, return it as-is (case-insensitive check)
101
16x
    if uuidRegex.MatchString(strings.ToLower(teamIdentifier)) {
102
10x
        return teamIdentifier, nil
103
10x
    }
104

105
    // Otherwise, query Linear for teams
106
6x
    query := `
107
6x
        query Teams {
108
6x
            teams {
109
6x
                nodes {
110
6x
                    id
111
6x
                    name
112
6x
                }
113
6x
            }
114
6x
        }
115
6x
    `
116
6x

117
6x
    requestBody := map[string]interface{}{
118
6x
        "query": query,
119
6x
    }
120
6x

121
6x
    jsonData, err := json.Marshal(requestBody)
122
6x
    if err != nil {
123
        return "", contextutils.WrapError(err, "failed to marshal team lookup request")
124
    }
125

126
6x
    apiURL := s.apiURL
127
6x
    if apiURL == "" {
128
        apiURL = LinearAPIEndpoint
129
    }
130
6x
    req, err := http.NewRequestWithContext(ctx, "POST", apiURL, bytes.NewBuffer(jsonData))
131
6x
    if err != nil {
132
        return "", contextutils.WrapError(err, "failed to create team lookup request")
133
    }
134

135
6x
    req.Header.Set("Content-Type", "application/json")
136
6x
    req.Header.Set("Authorization", s.config.Linear.APIKey)
137
6x
    req.Header.Set("User-Agent", "quizapp/1.0")
138
6x

139
6x
    resp, err := s.httpClient.Do(req)
140
6x
    if err != nil {
141
        return "", contextutils.WrapErrorf(err, "failed to query Linear teams")
142
    }
143
6x
    defer func() {
144
6x
        if closeErr := resp.Body.Close(); closeErr != nil {
145
            s.logger.Warn(ctx, "Failed to close response body", map[string]interface{}{"error": closeErr.Error()})
146
        }
147
    }()
148

149
6x
    body, err := io.ReadAll(resp.Body)
150
6x
    if err != nil {
151
        return "", contextutils.WrapError(err, "failed to read team lookup response")
152
    }
153

154
6x
    if resp.StatusCode != http.StatusOK {
155
2x
        return "", contextutils.NewAppError(
156
2x
            contextutils.ErrorCodeServiceUnavailable,
157
2x
            contextutils.SeverityError,
158
2x
            fmt.Sprintf("Linear API returned status %d when looking up teams: %s", resp.StatusCode, string(body)),
159
2x
            "",
160
2x
        )
161
2x
    }
162

163
4x
    var teamResponse struct {
164
4x
        Data struct {
165
4x
            Teams struct {
166
4x
                Nodes []struct {
167
4x
                    ID   string `json:"id"`
168
4x
                    Name string `json:"name"`
169
4x
                } `json:"nodes"`
170
4x
            } `json:"teams"`
171
4x
        } `json:"data"`
172
4x
        Errors []struct {
173
4x
            Message string `json:"message"`
174
4x
        } `json:"errors,omitempty"`
175
4x
    }
176
4x

177
4x
    if err := json.Unmarshal(body, &teamResponse); err != nil {
178
1x
        return "", contextutils.WrapError(err, "failed to unmarshal team lookup response")
179
1x
    }
180

181
3x
    if len(teamResponse.Errors) > 0 {
182
        return "", contextutils.NewAppError(
183
            contextutils.ErrorCodeServiceUnavailable,
184
            contextutils.SeverityError,
185
            fmt.Sprintf("Linear API error when looking up teams: %s", teamResponse.Errors[0].Message),
186
            "",
187
        )
188
    }
189

190
    // Find team by name (case-insensitive)
191
3x
    for _, team := range teamResponse.Data.Teams.Nodes {
192
4x
        if strings.EqualFold(team.Name, teamIdentifier) {
193
2x
            return team.ID, nil
194
2x
        }
195
    }
196

197
1x
    return "", contextutils.NewAppError(
198
1x
        contextutils.ErrorCodeInvalidInput,
199
1x
        contextutils.SeverityError,
200
1x
        fmt.Sprintf("Team '%s' not found in Linear", teamIdentifier),
201
1x
        "",
202
1x
    )
203
}
204

205
// getProjectIDByName looks up a project ID by name within a team, or returns the ID if it's already a UUID
206
7x
func (s *LinearService) getProjectIDByName(ctx context.Context, projectIdentifier, teamID string) (string, error) {
207
7x
    // If it looks like a UUID, return it as-is (case-insensitive check)
208
7x
    if uuidRegex.MatchString(strings.ToLower(projectIdentifier)) {
209
1x
        return projectIdentifier, nil
210
1x
    }
211

212
    // Otherwise, query Linear for projects in the team
213
6x
    query := `
214
6x
        query Projects($teamId: String!) {
215
6x
            team(id: $teamId) {
216
6x
                projects {
217
6x
                    nodes {
218
6x
                        id
219
6x
                        name
220
6x
                    }
221
6x
                }
222
6x
            }
223
6x
        }
224
6x
    `
225
6x

226
6x
    variables := map[string]interface{}{
227
6x
        "teamId": teamID,
228
6x
    }
229
6x

230
6x
    requestBody := map[string]interface{}{
231
6x
        "query":     query,
232
6x
        "variables": variables,
233
6x
    }
234
6x

235
6x
    jsonData, err := json.Marshal(requestBody)
236
6x
    if err != nil {
237
        return "", contextutils.WrapError(err, "failed to marshal project lookup request")
238
    }
239

240
6x
    apiURL := s.apiURL
241
6x
    if apiURL == "" {
242
        apiURL = LinearAPIEndpoint
243
    }
244
6x
    req, err := http.NewRequestWithContext(ctx, "POST", apiURL, bytes.NewBuffer(jsonData))
245
6x
    if err != nil {
246
        return "", contextutils.WrapError(err, "failed to create project lookup request")
247
    }
248

249
6x
    req.Header.Set("Content-Type", "application/json")
250
6x
    req.Header.Set("Authorization", s.config.Linear.APIKey)
251
6x
    req.Header.Set("User-Agent", "quizapp/1.0")
252
6x

253
6x
    resp, err := s.httpClient.Do(req)
254
6x
    if err != nil {
255
        return "", contextutils.WrapErrorf(err, "failed to query Linear projects")
256
    }
257
6x
    defer func() {
258
6x
        if closeErr := resp.Body.Close(); closeErr != nil {
259
            s.logger.Warn(ctx, "Failed to close response body", map[string]interface{}{"error": closeErr.Error()})
260
        }
261
    }()
262

263
6x
    body, err := io.ReadAll(resp.Body)
264
6x
    if err != nil {
265
        return "", contextutils.WrapError(err, "failed to read project lookup response")
266
    }
267

268
6x
    if resp.StatusCode != http.StatusOK {
269
1x
        return "", contextutils.NewAppError(
270
1x
            contextutils.ErrorCodeServiceUnavailable,
271
1x
            contextutils.SeverityError,
272
1x
            fmt.Sprintf("Linear API returned status %d when looking up projects: %s", resp.StatusCode, string(body)),
273
1x
            "",
274
1x
        )
275
1x
    }
276

277
5x
    var projectResponse struct {
278
5x
        Data struct {
279
5x
            Team struct {
280
5x
                Projects struct {
281
5x
                    Nodes []struct {
282
5x
                        ID   string `json:"id"`
283
5x
                        Name string `json:"name"`
284
5x
                    } `json:"nodes"`
285
5x
                } `json:"projects"`
286
5x
            } `json:"team"`
287
5x
        } `json:"data"`
288
5x
        Errors []struct {
289
5x
            Message string `json:"message"`
290
5x
        } `json:"errors,omitempty"`
291
5x
    }
292
5x

293
5x
    if err := json.Unmarshal(body, &projectResponse); err != nil {
294
1x
        return "", contextutils.WrapError(err, "failed to unmarshal project lookup response")
295
1x
    }
296

297
4x
    if len(projectResponse.Errors) > 0 {
298
1x
        return "", contextutils.NewAppError(
299
1x
            contextutils.ErrorCodeServiceUnavailable,
300
1x
            contextutils.SeverityError,
301
1x
            fmt.Sprintf("Linear API error when looking up projects: %s", projectResponse.Errors[0].Message),
302
1x
            "",
303
1x
        )
304
1x
    }
305

306
    // Find project by name (case-insensitive)
307
3x
    for _, project := range projectResponse.Data.Team.Projects.Nodes {
308
4x
        if strings.EqualFold(project.Name, projectIdentifier) {
309
3x
            return project.ID, nil
310
3x
        }
311
    }
312

313
    return "", contextutils.NewAppError(
314
        contextutils.ErrorCodeInvalidInput,
315
        contextutils.SeverityError,
316
        fmt.Sprintf("Project '%s' not found in team", projectIdentifier),
317
        "",
318
    )
319
}
320

321
// getLabelIDByName looks up a label ID by name, or returns the ID if it's already a UUID
322
7x
func (s *LinearService) getLabelIDByName(ctx context.Context, labelIdentifier string) (string, error) {
323
7x
    // If it looks like a UUID, return it as-is
324
7x
    if len(labelIdentifier) == 36 && strings.Contains(labelIdentifier, "-") {
325
1x
        return labelIdentifier, nil
326
1x
    }
327

328
    // Query Linear for both organization and team labels
329
    // First try organization-level labels (workspace-wide)
330
6x
    query := `
331
6x
        query Labels {
332
6x
            organization {
333
6x
                labels {
334
6x
                    nodes {
335
6x
                        id
336
6x
                        name
337
6x
                    }
338
6x
                }
339
6x
            }
340
6x
        }
341
6x
    `
342
6x

343
6x
    requestBody := map[string]interface{}{
344
6x
        "query": query,
345
6x
    }
346
6x

347
6x
    jsonData, err := json.Marshal(requestBody)
348
6x
    if err != nil {
349
        return "", contextutils.WrapError(err, "failed to marshal label lookup request")
350
    }
351

352
6x
    apiURL := s.apiURL
353
6x
    if apiURL == "" {
354
        apiURL = LinearAPIEndpoint
355
    }
356
6x
    req, err := http.NewRequestWithContext(ctx, "POST", apiURL, bytes.NewBuffer(jsonData))
357
6x
    if err != nil {
358
        return "", contextutils.WrapError(err, "failed to create label lookup request")
359
    }
360

361
6x
    req.Header.Set("Content-Type", "application/json")
362
6x
    req.Header.Set("Authorization", s.config.Linear.APIKey)
363
6x
    req.Header.Set("User-Agent", "quizapp/1.0")
364
6x

365
6x
    resp, err := s.httpClient.Do(req)
366
6x
    if err != nil {
367
        return "", contextutils.WrapErrorf(err, "failed to query Linear labels")
368
    }
369
6x
    defer func() {
370
6x
        if closeErr := resp.Body.Close(); closeErr != nil {
371
            s.logger.Warn(ctx, "Failed to close response body", map[string]interface{}{"error": closeErr.Error()})
372
        }
373
    }()
374

375
6x
    body, err := io.ReadAll(resp.Body)
376
6x
    if err != nil {
377
        return "", contextutils.WrapError(err, "failed to read label lookup response")
378
    }
379

380
6x
    if resp.StatusCode != http.StatusOK {
381
1x
        return "", contextutils.NewAppError(
382
1x
            contextutils.ErrorCodeServiceUnavailable,
383
1x
            contextutils.SeverityError,
384
1x
            fmt.Sprintf("Linear API returned status %d when looking up labels: %s", resp.StatusCode, string(body)),
385
1x
            "",
386
1x
        )
387
1x
    }
388

389
5x
    var labelResponse struct {
390
5x
        Data struct {
391
5x
            Organization struct {
392
5x
                Labels struct {
393
5x
                    Nodes []struct {
394
5x
                        ID   string `json:"id"`
395
5x
                        Name string `json:"name"`
396
5x
                    } `json:"nodes"`
397
5x
                } `json:"labels"`
398
5x
            } `json:"organization"`
399
5x
        } `json:"data"`
400
5x
        Errors []struct {
401
5x
            Message string `json:"message"`
402
5x
        } `json:"errors,omitempty"`
403
5x
    }
404
5x

405
5x
    if err := json.Unmarshal(body, &labelResponse); err != nil {
406
1x
        return "", contextutils.WrapError(err, "failed to unmarshal label lookup response")
407
1x
    }
408

409
4x
    if len(labelResponse.Errors) > 0 {
410
1x
        return "", contextutils.NewAppError(
411
1x
            contextutils.ErrorCodeServiceUnavailable,
412
1x
            contextutils.SeverityError,
413
1x
            fmt.Sprintf("Linear API error when looking up labels: %s", labelResponse.Errors[0].Message),
414
1x
            "",
415
1x
        )
416
1x
    }
417

418
    // Find label by name (case-insensitive) in organization labels
419
3x
    for _, label := range labelResponse.Data.Organization.Labels.Nodes {
420
3x
        if strings.EqualFold(label.Name, labelIdentifier) {
421
            return label.ID, nil
422
        }
423
    }
424

425
    // If not found in organization labels, try team-specific labels
426
    // Note: We need the team ID to query team labels, but we don't have it here
427
    // For now, we'll return an error. In the future, we could pass teamID to this function
428
    // or query team labels separately in CreateIssue after we have the team ID
429

430
3x
    return "", contextutils.NewAppError(
431
3x
        contextutils.ErrorCodeInvalidInput,
432
3x
        contextutils.SeverityError,
433
3x
        fmt.Sprintf("Label '%s' not found in Linear workspace. Make sure the label exists at the workspace level (Settings > Workspace > Labels)", labelIdentifier),
434
3x
        "",
435
3x
    )
436
}
437

438
// getTeamLabelIDByName looks up a team-specific label ID by name
439
7x
func (s *LinearService) getTeamLabelIDByName(ctx context.Context, teamID, labelIdentifier string) (string, error) {
440
7x
    // Query Linear for team-specific labels
441
7x
    query := `
442
7x
        query TeamLabels($teamId: String!) {
443
7x
            team(id: $teamId) {
444
7x
                labels {
445
7x
                    nodes {
446
7x
                        id
447
7x
                        name
448
7x
                    }
449
7x
                }
450
7x
            }
451
7x
        }
452
7x
    `
453
7x

454
7x
    requestBody := map[string]interface{}{
455
7x
        "query": query,
456
7x
        "variables": map[string]interface{}{
457
7x
            "teamId": teamID,
458
7x
        },
459
7x
    }
460
7x

461
7x
    jsonData, err := json.Marshal(requestBody)
462
7x
    if err != nil {
463
        return "", contextutils.WrapError(err, "failed to marshal team label lookup request")
464
    }
465

466
7x
    apiURL := s.apiURL
467
7x
    if apiURL == "" {
468
        apiURL = LinearAPIEndpoint
469
    }
470
7x
    req, err := http.NewRequestWithContext(ctx, "POST", apiURL, bytes.NewBuffer(jsonData))
471
7x
    if err != nil {
472
        return "", contextutils.WrapError(err, "failed to create team label lookup request")
473
    }
474

475
7x
    req.Header.Set("Content-Type", "application/json")
476
7x
    req.Header.Set("Authorization", s.config.Linear.APIKey)
477
7x
    req.Header.Set("User-Agent", "quizapp/1.0")
478
7x

479
7x
    resp, err := s.httpClient.Do(req)
480
7x
    if err != nil {
481
        return "", contextutils.WrapErrorf(err, "failed to query Linear team labels")
482
    }
483
7x
    defer func() {
484
7x
        if closeErr := resp.Body.Close(); closeErr != nil {
485
            s.logger.Warn(ctx, "Failed to close response body", map[string]interface{}{"error": closeErr.Error()})
486
        }
487
    }()
488

489
7x
    body, err := io.ReadAll(resp.Body)
490
7x
    if err != nil {
491
        return "", contextutils.WrapError(err, "failed to read team label lookup response")
492
    }
493

494
7x
    if resp.StatusCode != http.StatusOK {
495
1x
        return "", contextutils.NewAppError(
496
1x
            contextutils.ErrorCodeServiceUnavailable,
497
1x
            contextutils.SeverityError,
498
1x
            fmt.Sprintf("Linear API returned status %d when looking up team labels: %s", resp.StatusCode, string(body)),
499
1x
            "",
500
1x
        )
501
1x
    }
502

503
6x
    var labelResponse struct {
504
6x
        Data struct {
505
6x
            Team struct {
506
6x
                Labels struct {
507
6x
                    Nodes []struct {
508
6x
                        ID   string `json:"id"`
509
6x
                        Name string `json:"name"`
510
6x
                    } `json:"nodes"`
511
6x
                } `json:"labels"`
512
6x
            } `json:"team"`
513
6x
        } `json:"data"`
514
6x
        Errors []struct {
515
6x
            Message string `json:"message"`
516
6x
        } `json:"errors,omitempty"`
517
6x
    }
518
6x

519
6x
    if err := json.Unmarshal(body, &labelResponse); err != nil {
520
1x
        return "", contextutils.WrapError(err, "failed to unmarshal team label lookup response")
521
1x
    }
522

523
5x
    if len(labelResponse.Errors) > 0 {
524
1x
        return "", contextutils.NewAppError(
525
1x
            contextutils.ErrorCodeServiceUnavailable,
526
1x
            contextutils.SeverityError,
527
1x
            fmt.Sprintf("Linear API error when looking up team labels: %s", labelResponse.Errors[0].Message),
528
1x
            "",
529
1x
        )
530
1x
    }
531

532
    // Find label by name (case-insensitive)
533
4x
    for _, label := range labelResponse.Data.Team.Labels.Nodes {
534
4x
        if strings.EqualFold(label.Name, labelIdentifier) {
535
1x
            return label.ID, nil
536
1x
        }
537
    }
538

539
3x
    return "", contextutils.NewAppError(
540
3x
        contextutils.ErrorCodeInvalidInput,
541
3x
        contextutils.SeverityError,
542
3x
        fmt.Sprintf("Label '%s' not found in Linear team", labelIdentifier),
543
3x
        "",
544
3x
    )
545
}
546

547
// getProjectLabelIDByName looks up a project-specific label ID by name
548
7x
func (s *LinearService) getProjectLabelIDByName(ctx context.Context, projectID, labelIdentifier string) (string, error) {
549
7x
    // Query Linear for project-specific labels
550
7x
    query := `
551
7x
        query ProjectLabels($projectId: String!) {
552
7x
            project(id: $projectId) {
553
7x
                labels {
554
7x
                    nodes {
555
7x
                        id
556
7x
                        name
557
7x
                    }
558
7x
                }
559
7x
            }
560
7x
        }
561
7x
    `
562
7x

563
7x
    requestBody := map[string]interface{}{
564
7x
        "query": query,
565
7x
        "variables": map[string]interface{}{
566
7x
            "projectId": projectID,
567
7x
        },
568
7x
    }
569
7x

570
7x
    jsonData, err := json.Marshal(requestBody)
571
7x
    if err != nil {
572
        return "", contextutils.WrapError(err, "failed to marshal project label lookup request")
573
    }
574

575
7x
    apiURL := s.apiURL
576
7x
    if apiURL == "" {
577
        apiURL = LinearAPIEndpoint
578
    }
579
7x
    req, err := http.NewRequestWithContext(ctx, "POST", apiURL, bytes.NewBuffer(jsonData))
580
7x
    if err != nil {
581
        return "", contextutils.WrapError(err, "failed to create project label lookup request")
582
    }
583

584
7x
    req.Header.Set("Content-Type", "application/json")
585
7x
    req.Header.Set("Authorization", s.config.Linear.APIKey)
586
7x
    req.Header.Set("User-Agent", "quizapp/1.0")
587
7x

588
7x
    resp, err := s.httpClient.Do(req)
589
7x
    if err != nil {
590
        return "", contextutils.WrapErrorf(err, "failed to query Linear project labels")
591
    }
592
7x
    defer func() {
593
7x
        if closeErr := resp.Body.Close(); closeErr != nil {
594
            s.logger.Warn(ctx, "Failed to close response body", map[string]interface{}{"error": closeErr.Error()})
595
        }
596
    }()
597

598
7x
    body, err := io.ReadAll(resp.Body)
599
7x
    if err != nil {
600
        return "", contextutils.WrapError(err, "failed to read project label lookup response")
601
    }
602

603
7x
    if resp.StatusCode != http.StatusOK {
604
1x
        return "", contextutils.NewAppError(
605
1x
            contextutils.ErrorCodeServiceUnavailable,
606
1x
            contextutils.SeverityError,
607
1x
            fmt.Sprintf("Linear API returned status %d when looking up project labels: %s", resp.StatusCode, string(body)),
608
1x
            "",
609
1x
        )
610
1x
    }
611

612
6x
    var labelResponse struct {
613
6x
        Data struct {
614
6x
            Project struct {
615
6x
                Labels struct {
616
6x
                    Nodes []struct {
617
6x
                        ID   string `json:"id"`
618
6x
                        Name string `json:"name"`
619
6x
                    } `json:"nodes"`
620
6x
                } `json:"labels"`
621
6x
            } `json:"project"`
622
6x
        } `json:"data"`
623
6x
        Errors []struct {
624
6x
            Message string `json:"message"`
625
6x
        } `json:"errors,omitempty"`
626
6x
    }
627
6x

628
6x
    if err := json.Unmarshal(body, &labelResponse); err != nil {
629
1x
        return "", contextutils.WrapError(err, "failed to unmarshal project label lookup response")
630
1x
    }
631

632
5x
    if len(labelResponse.Errors) > 0 {
633
1x
        return "", contextutils.NewAppError(
634
1x
            contextutils.ErrorCodeServiceUnavailable,
635
1x
            contextutils.SeverityError,
636
1x
            fmt.Sprintf("Linear API error when looking up project labels: %s", labelResponse.Errors[0].Message),
637
1x
            "",
638
1x
        )
639
1x
    }
640

641
    // Find label by name (case-insensitive)
642
4x
    for _, label := range labelResponse.Data.Project.Labels.Nodes {
643
4x
        if strings.EqualFold(label.Name, labelIdentifier) {
644
2x
            return label.ID, nil
645
2x
        }
646
    }
647

648
2x
    return "", contextutils.NewAppError(
649
2x
        contextutils.ErrorCodeInvalidInput,
650
2x
        contextutils.SeverityError,
651
2x
        fmt.Sprintf("Label '%s' not found in Linear project", labelIdentifier),
652
2x
        "",
653
2x
    )
654
}
655

656
// CreateIssue creates a new issue in Linear
657
14x
func (s *LinearService) CreateIssue(ctx context.Context, title, description, teamID, projectID string, labels []string, state string) (result *LinearIssueResult, err error) {
658
14x
    ctx, span := observability.TraceFunction(ctx, "linear", "create_issue",
659
14x
        attribute.String("linear.title", title),
660
14x
        attribute.String("linear.team_id", teamID),
661
14x
        attribute.String("linear.project_id", projectID),
662
14x
    )
663
14x
    defer observability.FinishSpan(span, &err)
664
14x

665
14x
    if !s.config.Linear.Enabled {
666
1x
        err = contextutils.NewAppError(
667
1x
            contextutils.ErrorCodeServiceUnavailable,
668
1x
            contextutils.SeverityError,
669
1x
            "Linear integration is disabled",
670
1x
            "",
671
1x
        )
672
1x
        return nil, err
673
1x
    }
674

675
13x
    if s.config.Linear.APIKey == "" {
676
1x
        err = contextutils.NewAppError(
677
1x
            contextutils.ErrorCodeServiceUnavailable,
678
1x
            contextutils.SeverityError,
679
1x
            "Linear API key is not configured",
680
1x
            "",
681
1x
        )
682
1x
        return nil, err
683
1x
    }
684

685
12x
    if teamID == "" {
686
3x
        teamID = s.config.Linear.TeamID
687
3x
        if teamID == "" {
688
1x
            err = contextutils.NewAppError(
689
1x
                contextutils.ErrorCodeInvalidInput,
690
1x
                contextutils.SeverityError,
691
1x
                "Linear team ID or name is required",
692
1x
                "",
693
1x
            )
694
1x
            return nil, err
695
1x
        }
696
    }
697

698
    // Look up team ID by name if it's not a UUID
699
11x
    actualTeamID, err := s.getTeamIDByName(ctx, teamID)
700
11x
    if err != nil {
701
1x
        return nil, err
702
1x
    }
703
10x
    teamID = actualTeamID
704
10x

705
10x
    // Use default project ID if none provided and resolve it
706
10x
    actualProjectID := projectID
707
10x
    if actualProjectID == "" {
708
9x
        actualProjectID = s.config.Linear.ProjectID
709
9x
    }
710

711
    // Look up project ID by name if provided and not a UUID (needed for project label lookup)
712
10x
    if actualProjectID != "" {
713
3x
        resolvedProjectID, err := s.getProjectIDByName(ctx, actualProjectID, teamID)
714
3x
        if err != nil {
715
1x
            // If project lookup fails, log warning but continue without project
716
1x
            s.logger.Warn(ctx, "Failed to look up Linear project, continuing without project", map[string]interface{}{
717
1x
                "project_identifier": actualProjectID,
718
1x
                "error":              err.Error(),
719
1x
            })
720
1x
            actualProjectID = "" // Don't include project if lookup failed
721
1x
        } else {
722
2x
            actualProjectID = resolvedProjectID
723
2x
        }
724
    }
725

726
    // Look up label IDs by name if provided
727
    // Try organization labels first, then team labels, then project labels
728
10x
    var labelIDs []string
729
10x
    if len(labels) > 0 {
730
1x
        for _, labelName := range labels {
731
1x
            labelID, err := s.getLabelIDByName(ctx, labelName)
732
1x
            if err != nil {
733
1x
                // Try team-specific labels as fallback
734
1x
                labelID, err = s.getTeamLabelIDByName(ctx, teamID, labelName)
735
1x
                if err != nil {
736
1x
                    // Try project-specific labels if project ID is available
737
1x
                    if actualProjectID != "" {
738
1x
                        labelID, err = s.getProjectLabelIDByName(ctx, actualProjectID, labelName)
739
1x
                        if err != nil {
740
1x
                            // Log warning but continue without this label
741
1x
                            s.logger.Warn(ctx, "Failed to look up Linear label (tried organization, team, and project labels), continuing without it", map[string]interface{}{
742
1x
                                "label_name": labelName,
743
1x
                                "team_id":    teamID,
744
1x
                                "project_id": actualProjectID,
745
1x
                                "error":      err.Error(),
746
1x
                            })
747
1x
                            continue
748
                        }
749
                    } else {
750
                        // Log warning but continue without this label
751
                        s.logger.Warn(ctx, "Failed to look up Linear label (tried organization and team labels), continuing without it", map[string]interface{}{
752
                            "label_name": labelName,
753
                            "team_id":    teamID,
754
                            "error":      err.Error(),
755
                        })
756
                        continue
757
                    }
758
                }
759
            }
760
            labelIDs = append(labelIDs, labelID)
761
        }
762
9x
    } else if len(s.config.Linear.DefaultLabels) > 0 {
763
2x
        // Use default labels if none provided
764
2x
        for _, labelName := range s.config.Linear.DefaultLabels {
765
2x
            labelID, err := s.getLabelIDByName(ctx, labelName)
766
2x
            if err != nil {
767
2x
                // Try team-specific labels as fallback
768
2x
                labelID, err = s.getTeamLabelIDByName(ctx, teamID, labelName)
769
2x
                if err != nil {
770
1x
                    // Try project-specific labels if project ID is available
771
1x
                    if actualProjectID != "" {
772
1x
                        labelID, err = s.getProjectLabelIDByName(ctx, actualProjectID, labelName)
773
1x
                        if err != nil {
774
                            // Log warning but continue without this label
775
                            s.logger.Warn(ctx, "Failed to look up default Linear label (tried organization, team, and project labels), continuing without it", map[string]interface{}{
776
                                "label_name": labelName,
777
                                "team_id":    teamID,
778
                                "project_id": actualProjectID,
779
                                "error":      err.Error(),
780
                            })
781
                            continue
782
                        }
783
                    } else {
784
                        // Log warning but continue without this label
785
                        s.logger.Warn(ctx, "Failed to look up default Linear label (tried organization and team labels), continuing without it", map[string]interface{}{
786
                            "label_name": labelName,
787
                            "team_id":    teamID,
788
                            "error":      err.Error(),
789
                        })
790
                        continue
791
                    }
792
                }
793
            }
794
2x
            labelIDs = append(labelIDs, labelID)
795
        }
796
    }
797

798
    // Use default state if none provided
799
    // Note: State is not yet implemented (requires fetching state ID from Linear)
800
10x
    if state == "" {
801
10x
        _ = s.config.Linear.DefaultState // Will be used when state ID lookup is implemented
802
10x
    }
803

804
10x
    projectID = actualProjectID
805
10x

806
10x
    // Build GraphQL mutation
807
10x
    // Required fields: teamId, title
808
10x
    // Optional fields: description, projectId, assigneeId, labelIds (array of IDs), stateId (ID, not name)
809
10x
    mutation := `
810
10x
        mutation IssueCreate($input: IssueCreateInput!) {
811
10x
            issueCreate(input: $input) {
812
10x
                success
813
10x
                issue {
814
10x
                    id
815
10x
                    title
816
10x
                    url
817
10x
                }
818
10x
            }
819
10x
        }
820
10x
    `
821
10x

822
10x
    input := map[string]interface{}{
823
10x
        "title":  title,
824
10x
        "teamId": teamID,
825
10x
    }
826
10x

827
10x
    // Only add description if it's not empty (Linear may reject empty strings)
828
10x
    if description != "" {
829
10x
        input["description"] = description
830
10x
    }
831

832
    // Add project ID if provided (Linear accepts projectId as UUID or name)
833
    // Note: Linear expects projectId to be a valid UUID or identifier
834
10x
    if projectID != "" {
835
2x
        input["projectId"] = projectID
836
2x
    }
837

838
    // Add label IDs if any were resolved
839
10x
    if len(labelIDs) > 0 {
840
2x
        input["labelIds"] = labelIDs
841
2x
    }
842

843
10x
    variables := map[string]interface{}{
844
10x
        "input": input,
845
10x
    }
846
10x

847
10x
    requestBody := map[string]interface{}{
848
10x
        "query":     mutation,
849
10x
        "variables": variables,
850
10x
    }
851
10x

852
10x
    jsonData, err := json.Marshal(requestBody)
853
10x
    if err != nil {
854
        span.SetAttributes(attribute.String("error", err.Error()))
855
        return nil, contextutils.WrapError(err, "failed to marshal GraphQL request")
856
    }
857

858
10x
    apiURL := s.apiURL
859
10x
    if apiURL == "" {
860
        apiURL = LinearAPIEndpoint
861
    }
862
10x
    req, err := http.NewRequestWithContext(ctx, "POST", apiURL, bytes.NewBuffer(jsonData))
863
10x
    if err != nil {
864
        span.SetAttributes(attribute.String("error", err.Error()))
865
        return nil, contextutils.WrapError(err, "failed to create HTTP request")
866
    }
867

868
10x
    req.Header.Set("Content-Type", "application/json")
869
10x
    // Personal API keys should NOT use "Bearer" prefix per Linear docs
870
10x
    // OAuth2 tokens use "Bearer" prefix, but personal API keys use the key directly
871
10x
    req.Header.Set("Authorization", s.config.Linear.APIKey)
872
10x
    req.Header.Set("User-Agent", "quizapp/1.0")
873
10x

874
10x
    startTime := time.Now()
875
10x
    resp, err := s.httpClient.Do(req)
876
10x
    duration := time.Since(startTime)
877
10x

878
10x
    if err != nil {
879
1x
        s.logger.Error(ctx, "Linear HTTP request failed", err, map[string]interface{}{
880
1x
            "duration": duration.String(),
881
1x
        })
882
1x
        span.SetAttributes(
883
1x
            attribute.String("error", err.Error()),
884
1x
            attribute.String("duration", duration.String()),
885
1x
        )
886
1x
        return nil, contextutils.WrapErrorf(err, "Linear HTTP request failed after %v", duration)
887
1x
    }
888
9x
    defer func() {
889
9x
        if cerr := resp.Body.Close(); cerr != nil {
890
            s.logger.Warn(ctx, "Failed to close response body", map[string]interface{}{
891
                "error": cerr.Error(),
892
            })
893
        }
894
    }()
895

896
9x
    span.SetAttributes(
897
9x
        attribute.Int("http.status_code", resp.StatusCode),
898
9x
        attribute.String("duration", duration.String()),
899
9x
    )
900
9x

901
9x
    body, err := io.ReadAll(resp.Body)
902
9x
    if err != nil {
903
        span.SetAttributes(attribute.String("error", err.Error()))
904
        return nil, contextutils.WrapError(err, "failed to read response body")
905
    }
906

907
9x
    if resp.StatusCode != http.StatusOK {
908
1x
        s.logger.Error(ctx, "Linear API returned non-200 status", nil, map[string]interface{}{
909
1x
            "status_code": resp.StatusCode,
910
1x
            "body":        string(body),
911
1x
        })
912
1x
        span.SetAttributes(
913
1x
            attribute.String("error", fmt.Sprintf("Linear API returned status %d", resp.StatusCode)),
914
1x
            attribute.String("response_body", string(body)),
915
1x
        )
916
1x
        return nil, contextutils.NewAppError(
917
1x
            contextutils.ErrorCodeServiceUnavailable,
918
1x
            contextutils.SeverityError,
919
1x
            fmt.Sprintf("Linear API returned status %d: %s", resp.StatusCode, string(body)),
920
1x
            "",
921
1x
        )
922
1x
    }
923

924
8x
    var linearResp LinearIssueResponse
925
8x
    if err := json.Unmarshal(body, &linearResp); err != nil {
926
        span.SetAttributes(attribute.String("error", err.Error()))
927
        return nil, contextutils.WrapError(err, "failed to unmarshal Linear response")
928
    }
929

930
    // Check for GraphQL errors
931
8x
    if len(linearResp.Errors) > 0 {
932
1x
        errorMsg := linearResp.Errors[0].Message
933
1x
        // Log full error details including extensions which may contain validation details
934
1x
        errorDetails := make([]map[string]interface{}, len(linearResp.Errors))
935
1x
        for i, err := range linearResp.Errors {
936
1x
            errorDetails[i] = map[string]interface{}{
937
1x
                "message": err.Message,
938
1x
            }
939
1x
            if len(err.Extensions) > 0 {
940
                errorDetails[i]["extensions"] = err.Extensions
941
            }
942
1x
            if len(err.Path) > 0 {
943
                errorDetails[i]["path"] = err.Path
944
            }
945
        }
946

947
        // Build detailed error message with all error information
948
1x
        var detailedErrorMsg strings.Builder
949
1x
        detailedErrorMsg.WriteString(errorMsg)
950
1x
        if len(linearResp.Errors[0].Extensions) > 0 {
951
            detailedErrorMsg.WriteString("\nExtensions: ")
952
            extJSON, _ := json.Marshal(linearResp.Errors[0].Extensions)
953
            detailedErrorMsg.WriteString(string(extJSON))
954
        }
955
1x
        if len(linearResp.Errors[0].Path) > 0 {
956
            detailedErrorMsg.WriteString("\nPath: ")
957
            pathJSON, _ := json.Marshal(linearResp.Errors[0].Path)
958
            detailedErrorMsg.WriteString(string(pathJSON))
959
        }
960

961
1x
        s.logger.Error(ctx, "Linear GraphQL error", nil, map[string]interface{}{
962
1x
            "errors":        errorDetails,
963
1x
            "request_body":  string(jsonData), // Log the request for debugging
964
1x
            "full_response": string(body),     // Log full response for debugging
965
1x
        })
966
1x
        span.SetAttributes(attribute.String("error", detailedErrorMsg.String()))
967
1x
        return nil, contextutils.NewAppError(
968
1x
            contextutils.ErrorCodeServiceUnavailable,
969
1x
            contextutils.SeverityError,
970
1x
            detailedErrorMsg.String(),
971
1x
            "",
972
1x
        )
973
    }
974

975
7x
    if !linearResp.Data.IssueCreate.Success {
976
1x
        s.logger.Error(ctx, "Linear issue creation failed", nil, map[string]interface{}{})
977
1x
        span.SetAttributes(attribute.String("error", "Linear issue creation was not successful"))
978
1x
        return nil, contextutils.NewAppError(
979
1x
            contextutils.ErrorCodeServiceUnavailable,
980
1x
            contextutils.SeverityError,
981
1x
            "Linear issue creation was not successful",
982
1x
            "",
983
1x
        )
984
1x
    }
985

986
6x
    issue := linearResp.Data.IssueCreate.Issue
987
6x

988
6x
    // Construct the URL if not provided (Linear sometimes doesn't return it)
989
6x
    issueURL := issue.URL
990
6x
    if issueURL == "" {
991
1x
        issueURL = fmt.Sprintf("https://linear.app/issue/%s", issue.ID)
992
1x
    }
993

994
6x
    result = &LinearIssueResult{
995
6x
        IssueID:  issue.ID,
996
6x
        IssueURL: issueURL,
997
6x
        Title:    issue.Title,
998
6x
    }
999
6x

1000
6x
    s.logger.Info(ctx, "Linear issue created successfully", map[string]interface{}{
1001
6x
        "issue_id":  issue.ID,
1002
6x
        "issue_url": issueURL,
1003
6x
        "duration":  duration.String(),
1004
6x
    })
1005
6x

1006
6x
    span.SetAttributes(
1007
6x
        attribute.String("linear.issue_id", issue.ID),
1008
6x
        attribute.String("linear.issue_url", issueURL),
1009
6x
    )
1010
6x

1011
6x
    return result, nil
1012
}
1013


			
quizapp internal services worker_service.go
50.0%
Statements
1/2
1
package services
2

3
import (
4
    "fmt"
5

6
    contextutils "quizapp/internal/utils"
7
)
8

9
// NoQuestionsAvailableError is returned when no suitable questions can be found for assignment.
10
type NoQuestionsAvailableError struct {
11
    Language       string
12
    Level          string
13
    CandidateIDs   []int
14
    CandidateCount int
15
    TotalMatching  int
16
}
17

18
2x
func (e *NoQuestionsAvailableError) Error() string {
19
2x
    return fmt.Sprintf("no questions available for assignment (language=%s level=%s candidate_count=%d total_matching=%d)", e.Language, e.Level, e.CandidateCount, e.TotalMatching)
20
2x
}
21

22
// Unwrap allows errors.Is(..., contextutils.ErrNoQuestionsAvailable) to work.
23
func (e *NoQuestionsAvailableError) Unwrap() error {
24
    return contextutils.ErrNoQuestionsAvailable
25
}
26


			
quizapp internal services worker_service.go
62.2%
Statements
84/135
1
package services
2

3
import (
4
    "context"
5
    "encoding/json"
6
    "errors"
7
    "fmt"
8
    "io"
9
    "net/http"
10
    "net/url"
11
    "strings"
12

13
    "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
14
    "go.opentelemetry.io/otel/attribute"
15
    "go.opentelemetry.io/otel/trace"
16

17
    "quizapp/internal/config"
18
    "quizapp/internal/models"
19
    "quizapp/internal/observability"
20
    contextutils "quizapp/internal/utils"
21
)
22

23
// ErrSignupsDisabled is returned when user registration is disabled by config
24
var ErrSignupsDisabled = errors.New("user registration is currently disabled")
25

26
// OAuth sentinel errors
27
var (
28
    ErrOAuthCodeAlreadyUsed  = errors.New("authorization code has already been used")
29
    ErrOAuthClientConfig     = errors.New("OAuth client configuration error")
30
    ErrOAuthInvalidRequest   = errors.New("invalid OAuth request")
31
    ErrOAuthUnauthorized     = errors.New("OAuth client is not authorized")
32
    ErrOAuthUnsupportedGrant = errors.New("unsupported OAuth grant type")
33
)
34

35
// OAuthService handles OAuth authentication flows
36
type OAuthService struct {
37
    config           *config.Config
38
    TokenEndpoint    string // for testing/mocking
39
    UserInfoEndpoint string // for testing/mocking
40
    logger           *observability.Logger
41
}
42

43
// NewOAuthServiceWithLogger creates a new OAuth service with logger
44
7x
func NewOAuthServiceWithLogger(cfg *config.Config, logger *observability.Logger) *OAuthService {
45
7x
    return &OAuthService{
46
7x
        config:           cfg,
47
7x
        TokenEndpoint:    "https://oauth2.googleapis.com/token",
48
7x
        UserInfoEndpoint: "https://www.googleapis.com/oauth2/v2/userinfo",
49
7x
        logger:           logger,
50
7x
    }
51
7x
}
52

53
// GoogleUserInfo represents the user information returned by Google OAuth
54
type GoogleUserInfo struct {
55
    ID            string `json:"id"`
56
    Email         string `json:"email"`
57
    Name          string `json:"name"`
58
    GivenName     string `json:"given_name"`
59
    FamilyName    string `json:"family_name"`
60
    Picture       string `json:"picture"`
61
    VerifiedEmail bool   `json:"verified_email"`
62
}
63

64
// GoogleTokenResponse represents the token response from Google OAuth
65
type GoogleTokenResponse struct {
66
    AccessToken  string `json:"access_token"`
67
    TokenType    string `json:"token_type"`
68
    ExpiresIn    int    `json:"expires_in"`
69
    RefreshToken string `json:"refresh_token,omitempty"`
70
    IDToken      string `json:"id_token,omitempty"`
71
}
72

73
// GetGoogleAuthURL generates the Google OAuth authorization URL
74
1x
func (s *OAuthService) GetGoogleAuthURL(ctx context.Context, state string) string {
75
1x
    _, span := observability.TraceOAuthFunction(ctx, "get_google_auth_url",
76
1x
        attribute.String("oauth.state", state),
77
1x
        attribute.String("oauth.client_id", s.config.GoogleOAuthClientID),
78
1x
        attribute.String("oauth.redirect_url", s.config.GoogleOAuthRedirectURL),
79
1x
    )
80
1x
    defer span.End()
81
1x

82
1x
    // Debug logging
83
1x
    if s.config.GoogleOAuthClientID == "" {
84
        if s.logger != nil {
85
            s.logger.Warn(ctx, "Google OAuth client ID is not set", map[string]interface{}{"env_var": "GOOGLE_OAUTH_CLIENT_ID"})
86
        }
87
    }
88
1x
    if s.config.GoogleOAuthRedirectURL == "" {
89
        if s.logger != nil {
90
            s.logger.Warn(ctx, "Google OAuth redirect URL is not set", map[string]interface{}{"env_var": "GOOGLE_OAUTH_REDIRECT_URL"})
91
        }
92
    }
93

94
1x
    params := url.Values{}
95
1x
    params.Set("client_id", s.config.GoogleOAuthClientID)
96
1x
    params.Set("redirect_uri", s.config.GoogleOAuthRedirectURL)
97
1x
    params.Set("response_type", "code")
98
1x
    params.Set("scope", "openid email profile")
99
1x
    params.Set("state", state)
100
1x
    params.Set("access_type", "offline")
101
1x
    params.Set("prompt", "consent")
102
1x

103
1x
    return fmt.Sprintf("https://accounts.google.com/o/oauth2/v2/auth?%s", params.Encode())
104
}
105

106
// ExchangeCodeForToken exchanges the authorization code for an access token
107
2x
func (s *OAuthService) ExchangeCodeForToken(ctx context.Context, code string) (result0 *GoogleTokenResponse, err error) {
108
2x
    ctx, span := observability.TraceOAuthFunction(ctx, "exchange_code_for_token",
109
2x
        attribute.String("oauth.code", code),
110
2x
        attribute.String("oauth.token_endpoint", s.TokenEndpoint),
111
2x
    )
112
2x
    defer observability.FinishSpan(span, &err)
113
2x

114
2x
    data := url.Values{}
115
2x
    data.Set("client_id", s.config.GoogleOAuthClientID)
116
2x
    data.Set("client_secret", s.config.GoogleOAuthClientSecret)
117
2x
    data.Set("code", code)
118
2x
    data.Set("grant_type", "authorization_code")
119
2x
    data.Set("redirect_uri", s.config.GoogleOAuthRedirectURL)
120
2x

121
2x
    tokenURL := s.TokenEndpoint
122
2x
    if tokenURL == "" {
123
        tokenURL = "https://oauth2.googleapis.com/token"
124
    }
125

126
2x
    req, err := http.NewRequest("POST", tokenURL, strings.NewReader(data.Encode()))
127
2x
    if err != nil {
128
        span.SetAttributes(attribute.String("error", err.Error()))
129
        return nil, contextutils.WrapError(err, "failed to create token request")
130
    }
131

132
2x
    req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
133
2x

134
2x
    // Use instrumented HTTP client for automatic tracing with explicit span options
135
2x
    client := &http.Client{
136
2x
        Timeout: config.OAuthHTTPTimeout,
137
2x
        Transport: otelhttp.NewTransport(http.DefaultTransport,
138
2x
            otelhttp.WithSpanOptions(trace.WithSpanKind(trace.SpanKindClient)),
139
2x
        ),
140
2x
    }
141
2x
    resp, err := client.Do(req.WithContext(ctx))
142
2x
    if err != nil {
143
        span.SetAttributes(attribute.String("error", err.Error()))
144
        return nil, contextutils.WrapError(err, "failed to exchange code for token")
145
    }
146
2x
    defer func() {
147
2x
        cerr := resp.Body.Close()
148
2x
        if cerr != nil {
149
            s.logger.Warn(ctx, "Failed to close response body", map[string]interface{}{"error": cerr.Error()})
150
        }
151
    }()
152

153
2x
    span.SetAttributes(attribute.Int("http.status_code", resp.StatusCode))
154
2x

155
2x
    if resp.StatusCode != http.StatusOK {
156
        body, _ := io.ReadAll(resp.Body)
157

158
        // Try to parse the error response for better error messages
159
        var errorResp struct {
160
            Error            string `json:"error"`
161
            ErrorDescription string `json:"error_description"`
162
        }
163

164
        if json.Unmarshal(body, &errorResp) == nil {
165
            span.SetAttributes(
166
                attribute.String("oauth.error", errorResp.Error),
167
                attribute.String("oauth.error_description", errorResp.ErrorDescription),
168
            )
169
            switch errorResp.Error {
170
            case "invalid_grant":
171
                return nil, contextutils.WrapErrorf(ErrOAuthCodeAlreadyUsed, "please try signing in again")
172
            case "invalid_client":
173
                return nil, contextutils.WrapError(ErrOAuthClientConfig, "")
174
            case "invalid_request":
175
                return nil, contextutils.WrapError(ErrOAuthInvalidRequest, "")
176
            case "unauthorized_client":
177
                return nil, contextutils.WrapError(ErrOAuthUnauthorized, "")
178
            case "unsupported_grant_type":
179
                return nil, contextutils.WrapError(ErrOAuthUnsupportedGrant, "")
180
            default:
181
                return nil, contextutils.WrapErrorf(contextutils.ErrOAuthProviderError, "OAuth error: %s - %s", errorResp.Error, errorResp.ErrorDescription)
182
            }
183
        }
184

185
        return nil, contextutils.WrapErrorf(contextutils.ErrOAuthProviderError, "token exchange failed with status %d: %s", resp.StatusCode, string(body))
186
    }
187

188
2x
    var tokenResp GoogleTokenResponse
189
2x
    if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil {
190
        span.SetAttributes(attribute.String("error", err.Error()))
191
        return nil, contextutils.WrapError(err, "failed to decode token response")
192
    }
193

194
2x
    span.SetAttributes(
195
2x
        attribute.String("oauth.token_type", tokenResp.TokenType),
196
2x
        attribute.Int("oauth.expires_in", tokenResp.ExpiresIn),
197
2x
    )
198
2x

199
2x
    return &tokenResp, nil
200
}
201

202
// GetGoogleUserInfo retrieves user information from Google using the access token
203
2x
func (s *OAuthService) GetGoogleUserInfo(ctx context.Context, accessToken string) (result0 *GoogleUserInfo, err error) {
204
2x
    ctx, span := observability.TraceOAuthFunction(ctx, "get_google_user_info",
205
2x
        attribute.String("oauth.userinfo_endpoint", s.UserInfoEndpoint),
206
2x
    )
207
2x
    defer observability.FinishSpan(span, &err)
208
2x

209
2x
    userinfoURL := s.UserInfoEndpoint
210
2x
    if userinfoURL == "" {
211
        userinfoURL = "https://www.googleapis.com/oauth2/v2/userinfo"
212
    }
213

214
2x
    req, err := http.NewRequest("GET", userinfoURL, nil)
215
2x
    if err != nil {
216
        span.SetAttributes(attribute.String("error", err.Error()))
217
        return nil, contextutils.WrapError(err, "failed to create userinfo request")
218
    }
219

220
2x
    req.Header.Set("Authorization", "Bearer "+accessToken)
221
2x

222
2x
    // Use instrumented HTTP client for automatic tracing with explicit span options
223
2x
    client := &http.Client{
224
2x
        Timeout: config.OAuthHTTPTimeout,
225
2x
        Transport: otelhttp.NewTransport(http.DefaultTransport,
226
2x
            otelhttp.WithSpanOptions(trace.WithSpanKind(trace.SpanKindClient)),
227
2x
        ),
228
2x
    }
229
2x
    resp, err := client.Do(req.WithContext(ctx))
230
2x
    if err != nil {
231
        span.SetAttributes(attribute.String("error", err.Error()))
232
        return nil, contextutils.WrapError(err, "failed to get user info")
233
    }
234
2x
    defer func() {
235
2x
        cerr := resp.Body.Close()
236
2x
        if cerr != nil {
237
            s.logger.Warn(ctx, "Failed to close response body", map[string]interface{}{"error": cerr.Error()})
238
        }
239
    }()
240

241
2x
    span.SetAttributes(attribute.Int("http.status_code", resp.StatusCode))
242
2x

243
2x
    if resp.StatusCode != http.StatusOK {
244
        body, _ := io.ReadAll(resp.Body)
245
        span.SetAttributes(attribute.String("error", fmt.Sprintf("userinfo request failed with status %d: %s", resp.StatusCode, string(body))))
246
        return nil, contextutils.WrapErrorf(contextutils.ErrOAuthProviderError, "userinfo request failed with status %d: %s", resp.StatusCode, string(body))
247
    }
248

249
2x
    var userInfo GoogleUserInfo
250
2x
    if err := json.NewDecoder(resp.Body).Decode(&userInfo); err != nil {
251
        span.SetAttributes(attribute.String("error", err.Error()))
252
        return nil, contextutils.WrapError(err, "failed to decode user info")
253
    }
254

255
2x
    span.SetAttributes(
256
2x
        attribute.String("user.email", userInfo.Email),
257
2x
        attribute.String("user.id", userInfo.ID),
258
2x
        attribute.Bool("user.verified_email", userInfo.VerifiedEmail),
259
2x
    )
260
2x

261
2x
    return &userInfo, nil
262
}
263

264
// AuthenticateGoogleUser handles the complete Google OAuth flow
265
2x
func (s *OAuthService) AuthenticateGoogleUser(ctx context.Context, code string, userService UserServiceInterface) (result0 *models.User, err error) {
266
2x
    ctx, span := observability.TraceOAuthFunction(ctx, "authenticate_google_user",
267
2x
        attribute.String("oauth.code", code),
268
2x
    )
269
2x
    defer observability.FinishSpan(span, &err)
270
2x

271
2x
    // Exchange code for token
272
2x
    tokenResp, err := s.ExchangeCodeForToken(ctx, code)
273
2x
    if err != nil {
274
        span.SetAttributes(attribute.String("error", err.Error()))
275
        return nil, contextutils.WrapError(err, "failed to exchange code for token")
276
    }
277

278
    // Get user info from Google
279
2x
    userInfo, err := s.GetGoogleUserInfo(ctx, tokenResp.AccessToken)
280
2x
    if err != nil {
281
        span.SetAttributes(attribute.String("error", err.Error()))
282
        return nil, contextutils.WrapError(err, "failed to get user info")
283
    }
284

285
2x
    span.SetAttributes(
286
2x
        attribute.String("user.email", userInfo.Email),
287
2x
        attribute.String("user.id", userInfo.ID),
288
2x
    )
289
2x

290
2x
    // Check if user exists by email
291
2x
    existingUser, err := userService.GetUserByEmail(ctx, userInfo.Email)
292
2x
    if err != nil {
293
        span.SetAttributes(attribute.String("error", err.Error()))
294
        return nil, contextutils.WrapError(err, "failed to check existing user")
295
    }
296

297
2x
    if existingUser != nil {
298
1x
        // User exists, return the user
299
1x
        span.SetAttributes(
300
1x
            attribute.Int("user.id", existingUser.ID),
301
1x
            attribute.String("auth.result", "existing_user"),
302
1x
        )
303
1x
        return existingUser, nil
304
1x
    }
305

306
    // Check if signups are disabled before creating new user
307
1x
    if s.config != nil && s.config.IsSignupDisabled() {
308
        // Check if OAuth signup is allowed via whitelist
309
        if !s.config.IsOAuthSignupAllowed(userInfo.Email) {
310
            span.SetAttributes(
311
                attribute.String("auth.result", "oauth_signup_blocked"),
312
                attribute.String("user.email", userInfo.Email),
313
            )
314
            return nil, ErrSignupsDisabled
315
        }
316
        // Allow OAuth signup for whitelisted email/domain
317
        span.SetAttributes(
318
            attribute.String("auth.result", "oauth_signup_allowed"),
319
            attribute.String("user.email", userInfo.Email),
320
        )
321
    }
322

323
    // User doesn't exist, create new user
324
    // Use email as username (we'll handle conflicts)
325
1x
    username := userInfo.Email
326
1x
    email := userInfo.Email
327
1x

328
1x
    // Check if username already exists, if so, append a number
329
1x
    counter := 1
330
1x
    for {
331
1x
        existingUser, err := userService.GetUserByUsername(ctx, username)
332
1x
        if err != nil {
333
            span.SetAttributes(attribute.String("error", err.Error()))
334
            return nil, contextutils.WrapError(err, "failed to check username availability")
335
        }
336
1x
        if existingUser == nil {
337
1x
            break
338
        }
339
        username = fmt.Sprintf("%s_%d", userInfo.Email, counter)
340
        counter++
341
    }
342

343
1x
    span.SetAttributes(
344
1x
        attribute.String("user.username", username),
345
1x
        attribute.String("user.email", email),
346
1x
        attribute.String("auth.result", "new_user"),
347
1x
    )
348
1x

349
1x
    // Create user with default settings
350
1x
    // Use email as username (we'll handle conflicts)
351
1x
    user, err := userService.CreateUserWithEmailAndTimezone(ctx, username, email, "UTC", "italian", "beginner")
352
1x
    if err != nil {
353
        span.SetAttributes(attribute.String("error", err.Error()))
354
        return nil, contextutils.WrapError(err, "failed to create user")
355
    }
356

357
1x
    span.SetAttributes(attribute.Int("user.id", user.ID))
358
1x

359
1x
    return user, nil
360
}
361


			
quizapp internal services worker_service.go
72.6%
Statements
884/1218
1
package services
2

3
import (
4
    "context"
5
    "database/sql"
6
    "errors"
7
    "fmt"
8
    "math/rand"
9
    "strconv"
10
    "strings"
11

12
    "quizapp/internal/config"
13
    "quizapp/internal/models"
14
    "quizapp/internal/observability"
15
    contextutils "quizapp/internal/utils"
16

17
    "go.opentelemetry.io/otel/codes"
18
    "go.opentelemetry.io/otel/trace"
19
)
20

21
// QuestionServiceInterface defines the interface for question-related operations.
22
// This allows for easier mocking in tests.
23
type QuestionServiceInterface interface {
24
    SaveQuestion(ctx context.Context, question *models.Question) error
25
    AssignQuestionToUser(ctx context.Context, questionID, userID int) error
26
    GetQuestionByID(ctx context.Context, id int) (*models.Question, error)
27
    GetQuestionWithStats(ctx context.Context, id int) (*QuestionWithStats, error)
28
    GetQuestionsByFilter(ctx context.Context, userID int, language, level string, questionType models.QuestionType, limit int) ([]models.Question, error)
29
    GetNextQuestion(ctx context.Context, userID int, language, level string, qType models.QuestionType) (*QuestionWithStats, error)
30
    GetAdaptiveQuestionsForDaily(ctx context.Context, userID int, language, level string, limit int) ([]*QuestionWithStats, error)
31
    ReportQuestion(ctx context.Context, questionID, userID int, reportReason string) error
32
    GetQuestionStats(ctx context.Context) (map[string]interface{}, error)
33
    GetDetailedQuestionStats(ctx context.Context) (map[string]interface{}, error)
34
    GetRecentQuestionContentsForUser(ctx context.Context, userID, limit int) ([]string, error)
35
    GetReportedQuestions(ctx context.Context) ([]*ReportedQuestionWithUser, error)
36
    MarkQuestionAsFixed(ctx context.Context, questionID int) error
37
    UpdateQuestion(ctx context.Context, questionID int, content map[string]interface{}, correctAnswerIndex int, explanation string) error
38
    DeleteQuestion(ctx context.Context, questionID int) error
39
    GetUserQuestions(ctx context.Context, userID, limit int) ([]*models.Question, error)
40
    GetUserQuestionsWithStats(ctx context.Context, userID, limit int) ([]*QuestionWithStats, error)
41
    GetQuestionsPaginated(ctx context.Context, userID, page, pageSize int, search, typeFilter, statusFilter string) ([]*QuestionWithStats, int, error)
42
    GetAllQuestionsPaginated(ctx context.Context, page, pageSize int, search, typeFilter, statusFilter, languageFilter, levelFilter string, userID *int) ([]*QuestionWithStats, int, error)
43
    GetReportedQuestionsPaginated(ctx context.Context, page, pageSize int, search, typeFilter, languageFilter, levelFilter string) ([]*QuestionWithStats, int, error)
44
    GetReportedQuestionsStats(ctx context.Context) (map[string]interface{}, error)
45
    GetUserQuestionCount(ctx context.Context, userID int) (int, error)
46
    GetUserResponseCount(ctx context.Context, userID int) (int, error)
47
    GetRandomGlobalQuestionForUser(ctx context.Context, userID int, language, level string, qType models.QuestionType) (*QuestionWithStats, error)
48
    GetUsersForQuestion(ctx context.Context, questionID int) ([]*models.User, int, error)
49
    AssignUsersToQuestion(ctx context.Context, questionID int, userIDs []int) error
50
    UnassignUsersFromQuestion(ctx context.Context, questionID int, userIDs []int) error
51
    DB() *sql.DB
52
}
53

54
// QuestionService provides methods for question management.
55
type QuestionService struct {
56
    db              *sql.DB
57
    learningService *LearningService
58
    logger          *observability.Logger
59
    cfg             *config.Config
60
}
61

62
// Shared query constants to eliminate duplication
63
const (
64
    // questionSelectFields contains all question fields for SELECT queries
65
    questionSelectFields = `id, type, language, level, difficulty_score, content, correct_answer, explanation, created_at, status, topic_category, grammar_focus, vocabulary_domain, scenario, style_modifier, difficulty_modifier, time_context`
66
)
67

68
// scanQuestionFromRow scans a database row into a models.Question struct
69
14x
func (s *QuestionService) scanQuestionFromRow(row *sql.Row) (result0 *models.Question, err error) {
70
14x
    question := &models.Question{}
71
14x
    var contentJSON string
72
14x
    var topicCategory sql.NullString
73
14x
    var grammarFocus sql.NullString
74
14x
    var vocabularyDomain sql.NullString
75
14x
    var scenario sql.NullString
76
14x
    var styleModifier sql.NullString
77
14x
    var difficultyModifier sql.NullString
78
14x
    var timeContext sql.NullString
79
14x

80
14x
    err = row.Scan(
81
14x
        &question.ID,
82
14x
        &question.Type,
83
14x
        &question.Language,
84
14x
        &question.Level,
85
14x
        &question.DifficultyScore,
86
14x
        &contentJSON,
87
14x
        &question.CorrectAnswer,
88
14x
        &question.Explanation,
89
14x
        &question.CreatedAt,
90
14x
        &question.Status,
91
14x
        &topicCategory,
92
14x
        &grammarFocus,
93
14x
        &vocabularyDomain,
94
14x
        &scenario,
95
14x
        &styleModifier,
96
14x
        &difficultyModifier,
97
14x
        &timeContext,
98
14x
    )
99
14x
    if err != nil {
100
1x
        return nil, err
101
1x
    }
102

103
    // Set optional string fields if they have values
104
13x
    if topicCategory.Valid {
105
13x
        question.TopicCategory = topicCategory.String
106
13x
    }
107
13x
    if grammarFocus.Valid {
108
13x
        question.GrammarFocus = grammarFocus.String
109
13x
    }
110
13x
    if vocabularyDomain.Valid {
111
13x
        question.VocabularyDomain = vocabularyDomain.String
112
13x
    }
113
13x
    if scenario.Valid {
114
13x
        question.Scenario = scenario.String
115
13x
    }
116
13x
    if styleModifier.Valid {
117
13x
        question.StyleModifier = styleModifier.String
118
13x
    }
119
13x
    if difficultyModifier.Valid {
120
13x
        question.DifficultyModifier = difficultyModifier.String
121
13x
    }
122
13x
    if timeContext.Valid {
123
13x
        question.TimeContext = timeContext.String
124
13x
    }
125

126
13x
    if err := question.UnmarshalContentFromJSON(contentJSON); err != nil {
127
        return nil, err
128
    }
129

130
13x
    return question, nil
131
}
132

133
// scanQuestionFromRows scans a database rows into a models.Question struct
134
13x
func (s *QuestionService) scanQuestionFromRows(rows *sql.Rows) (result0 *models.Question, err error) {
135
13x
    question := &models.Question{}
136
13x
    var contentJSON string
137
13x
    var topicCategory sql.NullString
138
13x
    var grammarFocus sql.NullString
139
13x
    var vocabularyDomain sql.NullString
140
13x
    var scenario sql.NullString
141
13x
    var styleModifier sql.NullString
142
13x
    var difficultyModifier sql.NullString
143
13x
    var timeContext sql.NullString
144
13x

145
13x
    err = rows.Scan(
146
13x
        &question.ID,
147
13x
        &question.Type,
148
13x
        &question.Language,
149
13x
        &question.Level,
150
13x
        &question.DifficultyScore,
151
13x
        &contentJSON,
152
13x
        &question.CorrectAnswer,
153
13x
        &question.Explanation,
154
13x
        &question.CreatedAt,
155
13x
        &question.Status,
156
13x
        &topicCategory,
157
13x
        &grammarFocus,
158
13x
        &vocabularyDomain,
159
13x
        &scenario,
160
13x
        &styleModifier,
161
13x
        &difficultyModifier,
162
13x
        &timeContext,
163
13x
    )
164
13x
    if err != nil {
165
        return nil, err
166
    }
167

168
    // Set optional string fields if they have values
169
13x
    if topicCategory.Valid {
170
13x
        question.TopicCategory = topicCategory.String
171
13x
    }
172
13x
    if grammarFocus.Valid {
173
13x
        question.GrammarFocus = grammarFocus.String
174
13x
    }
175
13x
    if vocabularyDomain.Valid {
176
13x
        question.VocabularyDomain = vocabularyDomain.String
177
13x
    }
178
13x
    if scenario.Valid {
179
13x
        question.Scenario = scenario.String
180
13x
    }
181
13x
    if styleModifier.Valid {
182
13x
        question.StyleModifier = styleModifier.String
183
13x
    }
184
13x
    if difficultyModifier.Valid {
185
13x
        question.DifficultyModifier = difficultyModifier.String
186
13x
    }
187
13x
    if timeContext.Valid {
188
13x
        question.TimeContext = timeContext.String
189
13x
    }
190

191
13x
    if err := question.UnmarshalContentFromJSON(contentJSON); err != nil {
192
        return nil, err
193
    }
194

195
13x
    return question, nil
196
}
197

198
// scanQuestionBasicFromRows scans a database rows into a models.Question struct (basic fields only)
199
6x
func (s *QuestionService) scanQuestionBasicFromRows(rows *sql.Rows) (result0 *models.Question, err error) {
200
6x
    question := &models.Question{}
201
6x
    var contentJSON string
202
6x

203
6x
    err = rows.Scan(
204
6x
        &question.ID,
205
6x
        &question.Type,
206
6x
        &question.Language,
207
6x
        &question.Level,
208
6x
        &question.DifficultyScore,
209
6x
        &contentJSON,
210
6x
        &question.CorrectAnswer,
211
6x
        &question.Explanation,
212
6x
        &question.CreatedAt,
213
6x
        &question.Status,
214
6x
    )
215
6x
    if err != nil {
216
        return nil, err
217
    }
218

219
6x
    if err := question.UnmarshalContentFromJSON(contentJSON); err != nil {
220
        return nil, err
221
    }
222

223
6x
    return question, nil
224
}
225

226
// scanQuestionWithStatsFromRows scans a database rows into a QuestionWithStats struct
227
66x
func (s *QuestionService) scanQuestionWithStatsFromRows(rows *sql.Rows) (result0 *QuestionWithStats, err error) {
228
66x
    questionWithStats := &QuestionWithStats{
229
66x
        Question: &models.Question{},
230
66x
    }
231
66x
    var contentJSON string
232
66x

233
66x
    err = rows.Scan(
234
66x
        &questionWithStats.ID,
235
66x
        &questionWithStats.Type,
236
66x
        &questionWithStats.Language,
237
66x
        &questionWithStats.Level,
238
66x
        &questionWithStats.DifficultyScore,
239
66x
        &contentJSON,
240
66x
        &questionWithStats.CorrectAnswer,
241
66x
        &questionWithStats.Explanation,
242
66x
        &questionWithStats.CreatedAt,
243
66x
        &questionWithStats.Status,
244
66x
        &questionWithStats.CorrectCount,
245
66x
        &questionWithStats.IncorrectCount,
246
66x
        &questionWithStats.TotalResponses,
247
66x
        &questionWithStats.UserCount,
248
66x
    )
249
66x
    if err != nil {
250
        return nil, err
251
    }
252

253
66x
    if err := questionWithStats.UnmarshalContentFromJSON(contentJSON); err != nil {
254
        return nil, err
255
    }
256

257
66x
    return questionWithStats, nil
258
}
259

260
// scanQuestionWithStatsAndAllFieldsFromRows scans a database rows into a QuestionWithStats struct (with all fields)
261
67x
func (s *QuestionService) scanQuestionWithStatsAndAllFieldsFromRows(rows *sql.Rows) (result0 *QuestionWithStats, err error) {
262
67x
    questionWithStats := &QuestionWithStats{
263
67x
        Question: &models.Question{},
264
67x
    }
265
67x
    var contentJSON string
266
67x
    var topicCategory sql.NullString
267
67x
    var grammarFocus sql.NullString
268
67x
    var vocabularyDomain sql.NullString
269
67x
    var scenario sql.NullString
270
67x
    var styleModifier sql.NullString
271
67x
    var difficultyModifier sql.NullString
272
67x
    var timeContext sql.NullString
273
67x

274
67x
    err = rows.Scan(
275
67x
        &questionWithStats.ID,
276
67x
        &questionWithStats.Type,
277
67x
        &questionWithStats.Language,
278
67x
        &questionWithStats.Level,
279
67x
        &questionWithStats.DifficultyScore,
280
67x
        &contentJSON,
281
67x
        &questionWithStats.CorrectAnswer,
282
67x
        &questionWithStats.Explanation,
283
67x
        &questionWithStats.CreatedAt,
284
67x
        &questionWithStats.Status,
285
67x
        &topicCategory,
286
67x
        &grammarFocus,
287
67x
        &vocabularyDomain,
288
67x
        &scenario,
289
67x
        &styleModifier,
290
67x
        &difficultyModifier,
291
67x
        &timeContext,
292
67x
        &questionWithStats.CorrectCount,
293
67x
        &questionWithStats.IncorrectCount,
294
67x
        &questionWithStats.TotalResponses,
295
67x
        &questionWithStats.UserCount,
296
67x
    )
297
67x
    if err != nil {
298
        return nil, err
299
    }
300

301
    // Set optional string fields if they have values
302
67x
    if topicCategory.Valid {
303
67x
        questionWithStats.TopicCategory = topicCategory.String
304
67x
    }
305
67x
    if grammarFocus.Valid {
306
67x
        questionWithStats.GrammarFocus = grammarFocus.String
307
67x
    }
308
67x
    if vocabularyDomain.Valid {
309
67x
        questionWithStats.VocabularyDomain = vocabularyDomain.String
310
67x
    }
311
67x
    if scenario.Valid {
312
67x
        questionWithStats.Scenario = scenario.String
313
67x
    }
314
67x
    if styleModifier.Valid {
315
67x
        questionWithStats.StyleModifier = styleModifier.String
316
67x
    }
317
67x
    if difficultyModifier.Valid {
318
67x
        questionWithStats.DifficultyModifier = difficultyModifier.String
319
67x
    }
320
67x
    if timeContext.Valid {
321
67x
        questionWithStats.TimeContext = timeContext.String
322
67x
    }
323

324
67x
    if err := questionWithStats.UnmarshalContentFromJSON(contentJSON); err != nil {
325
        return nil, err
326
    }
327

328
67x
    return questionWithStats, nil
329
}
330

331
// scanQuestionWithPriorityAndStatsFromRows scans a database rows into a QuestionWithStats struct (with priority and stats)
332
3370x
func (s *QuestionService) scanQuestionWithPriorityAndStatsFromRows(rows *sql.Rows) (result0 *QuestionWithStats, err error) {
333
3370x
    questionWithStats := &QuestionWithStats{
334
3370x
        Question: &models.Question{},
335
3370x
    }
336
3370x
    var contentJSON string
337
3370x
    var priorityScore float64
338
3370x
    var timesAnswered int
339
3370x
    var lastAnsweredAt sql.NullTime
340
3370x
    var confidenceLevel sql.NullInt32
341
3370x
    var topicCategory sql.NullString
342
3370x
    var grammarFocus sql.NullString
343
3370x
    var vocabularyDomain sql.NullString
344
3370x
    var scenario sql.NullString
345
3370x
    var styleModifier sql.NullString
346
3370x
    var difficultyModifier sql.NullString
347
3370x
    var timeContext sql.NullString
348
3370x

349
3370x
    err = rows.Scan(
350
3370x
        &questionWithStats.ID,
351
3370x
        &questionWithStats.Type,
352
3370x
        &questionWithStats.Language,
353
3370x
        &questionWithStats.Level,
354
3370x
        &questionWithStats.DifficultyScore,
355
3370x
        &contentJSON,
356
3370x
        &questionWithStats.CorrectAnswer,
357
3370x
        &questionWithStats.Explanation,
358
3370x
        &questionWithStats.CreatedAt,
359
3370x
        &questionWithStats.Status,
360
3370x
        &topicCategory,
361
3370x
        &grammarFocus,
362
3370x
        &vocabularyDomain,
363
3370x
        &scenario,
364
3370x
        &styleModifier,
365
3370x
        &difficultyModifier,
366
3370x
        &timeContext,
367
3370x
        &priorityScore,
368
3370x
        &timesAnswered,
369
3370x
        &lastAnsweredAt,
370
3370x
        &questionWithStats.CorrectCount,
371
3370x
        &questionWithStats.IncorrectCount,
372
3370x
        &questionWithStats.TotalResponses,
373
3370x
        &confidenceLevel,
374
3370x
    )
375
3370x
    if err != nil {
376
        return nil, err
377
    }
378

379
    // Set optional string fields if they have values
380
3370x
    if topicCategory.Valid {
381
3330x
        questionWithStats.TopicCategory = topicCategory.String
382
3330x
    }
383
3370x
    if grammarFocus.Valid {
384
3330x
        questionWithStats.GrammarFocus = grammarFocus.String
385
3330x
    }
386
3370x
    if vocabularyDomain.Valid {
387
3330x
        questionWithStats.VocabularyDomain = vocabularyDomain.String
388
3330x
    }
389
3370x
    if scenario.Valid {
390
3330x
        questionWithStats.Scenario = scenario.String
391
3330x
    }
392
3370x
    if styleModifier.Valid {
393
3330x
        questionWithStats.StyleModifier = styleModifier.String
394
3330x
    }
395
3370x
    if difficultyModifier.Valid {
396
3330x
        questionWithStats.DifficultyModifier = difficultyModifier.String
397
3330x
    }
398
3370x
    if timeContext.Valid {
399
3330x
        questionWithStats.TimeContext = timeContext.String
400
3330x
    }
401

402
3370x
    if err := questionWithStats.UnmarshalContentFromJSON(contentJSON); err != nil {
403
        return nil, err
404
    }
405

406
    // Set confidence level if it exists
407
3370x
    if confidenceLevel.Valid {
408
        level := int(confidenceLevel.Int32)
409
        questionWithStats.ConfidenceLevel = &level
410
    }
411

412
    // Populate per-user times answered from the scanned value
413
3370x
    questionWithStats.TimesAnswered = timesAnswered
414
3370x

415
3370x
    return questionWithStats, nil
416
}
417

418
// scanQuestionWithStatsAndReportersFromRows scans a database rows into a QuestionWithStats struct (with reporter information)
419
11x
func (s *QuestionService) scanQuestionWithStatsAndReportersFromRows(rows *sql.Rows) (result0 *QuestionWithStats, err error) {
420
11x
    questionWithStats := &QuestionWithStats{
421
11x
        Question: &models.Question{},
422
11x
    }
423
11x
    var contentJSON string
424
11x
    var reporters sql.NullString
425
11x
    var reportReasons sql.NullString
426
11x
    var topicCategory sql.NullString
427
11x
    var grammarFocus sql.NullString
428
11x
    var vocabularyDomain sql.NullString
429
11x
    var scenario sql.NullString
430
11x
    var styleModifier sql.NullString
431
11x
    var difficultyModifier sql.NullString
432
11x
    var timeContext sql.NullString
433
11x

434
11x
    err = rows.Scan(
435
11x
        &questionWithStats.ID,
436
11x
        &questionWithStats.Type,
437
11x
        &questionWithStats.Language,
438
11x
        &questionWithStats.Level,
439
11x
        &questionWithStats.DifficultyScore,
440
11x
        &contentJSON,
441
11x
        &questionWithStats.CorrectAnswer,
442
11x
        &questionWithStats.Explanation,
443
11x
        &questionWithStats.CreatedAt,
444
11x
        &questionWithStats.Status,
445
11x
        &topicCategory,
446
11x
        &grammarFocus,
447
11x
        &vocabularyDomain,
448
11x
        &scenario,
449
11x
        &styleModifier,
450
11x
        &difficultyModifier,
451
11x
        &timeContext,
452
11x
        &questionWithStats.CorrectCount,
453
11x
        &questionWithStats.IncorrectCount,
454
11x
        &questionWithStats.TotalResponses,
455
11x
        &reporters,
456
11x
        &reportReasons,
457
11x
    )
458
11x
    if err != nil {
459
        return nil, err
460
    }
461

462
    // Set optional string fields if they have values
463
11x
    if topicCategory.Valid {
464
11x
        questionWithStats.TopicCategory = topicCategory.String
465
11x
    }
466
11x
    if grammarFocus.Valid {
467
11x
        questionWithStats.GrammarFocus = grammarFocus.String
468
11x
    }
469
11x
    if vocabularyDomain.Valid {
470
11x
        questionWithStats.VocabularyDomain = vocabularyDomain.String
471
11x
    }
472
11x
    if scenario.Valid {
473
11x
        questionWithStats.Scenario = scenario.String
474
11x
    }
475
11x
    if styleModifier.Valid {
476
11x
        questionWithStats.StyleModifier = styleModifier.String
477
11x
    }
478
11x
    if difficultyModifier.Valid {
479
11x
        questionWithStats.DifficultyModifier = difficultyModifier.String
480
11x
    }
481
11x
    if timeContext.Valid {
482
11x
        questionWithStats.TimeContext = timeContext.String
483
11x
    }
484

485
11x
    if err := questionWithStats.UnmarshalContentFromJSON(contentJSON); err != nil {
486
        return nil, err
487
    }
488

489
    // Store reporter information
490
11x
    if reporters.Valid && reporters.String != "" {
491
11x
        questionWithStats.Reporters = reporters.String
492
11x
    }
493

494
    // Store report reasons information
495
11x
    if reportReasons.Valid && reportReasons.String != "" {
496
11x
        questionWithStats.ReportReasons = reportReasons.String
497
11x
    }
498

499
11x
    return questionWithStats, nil
500
}
501

502
// getQuestionByQuery is a shared method for getting a question by any query
503
14x
func (s *QuestionService) getQuestionByQuery(ctx context.Context, query string, args ...interface{}) (result0 *models.Question, err error) {
504
14x
    row := s.db.QueryRowContext(ctx, query, args...)
505
14x
    var question *models.Question
506
14x
    question, err = s.scanQuestionFromRow(row)
507
14x
    if err != nil {
508
1x
        if errors.Is(err, sql.ErrNoRows) {
509
1x
            return nil, sql.ErrNoRows // Propagate sql.ErrNoRows for not found
510
1x
        }
511
        return nil, err
512
    }
513
13x
    return question, nil
514
}
515

516
// NewQuestionServiceWithLogger creates a new QuestionService instance with logger
517
72x
func NewQuestionServiceWithLogger(db *sql.DB, learningService *LearningService, cfg *config.Config, logger *observability.Logger) *QuestionService {
518
72x
    if db == nil {
519
4x
        panic("database connection cannot be nil")
520
    }
521
68x
    if logger == nil {
522
        panic("logger cannot be nil")
523
    }
524

525
68x
    return &QuestionService{
526
68x
        db:              db,
527
68x
        learningService: learningService,
528
68x
        logger:          logger,
529
68x
        cfg:             cfg,
530
68x
    }
531
}
532

533
// getDailyRepeatAvoidDays returns the configured number of days to avoid repeating
534
// questions in daily assignments. Defaults to 7 when not configured or invalid.
535
349x
func (s *QuestionService) getDailyRepeatAvoidDays() int {
536
349x
    if s.cfg != nil {
537
349x
        if days := s.cfg.Server.DailyRepeatAvoidDays; days > 0 {
538
349x
            return days
539
349x
        }
540
    }
541
    return 7
542
}
543

544
// SaveQuestion saves a question to the database
545
190x
func (s *QuestionService) SaveQuestion(ctx context.Context, question *models.Question) (err error) {
546
190x
    ctx, span := observability.TraceQuestionFunction(ctx, "save_question", observability.AttributeQuestion(question))
547
190x
    defer func() {
548
190x
        if err != nil {
549
            span.RecordError(err, trace.WithStackTrace(true))
550
            span.SetStatus(codes.Error, err.Error())
551
        }
552
190x
        span.End()
553
    }()
554
190x
    var contentJSON []byte
555
190x
    contentJSONStr, err := question.MarshalContentToJSON()
556
190x
    if err != nil {
557
        return contextutils.WrapError(err, "failed to marshal question content")
558
    }
559
190x
    contentJSON = []byte(contentJSONStr)
560
190x
    if err != nil {
561
        return contextutils.WrapError(err, "failed to marshal question content")
562
    }
563

564
190x
    if question.Status == "" {
565
7x
        question.Status = models.QuestionStatusActive
566
7x
    }
567

568
190x
    query := `
569
190x
        INSERT INTO questions (type, language, level, difficulty_score, content, correct_answer, explanation, status, topic_category, grammar_focus, vocabulary_domain, scenario, style_modifier, difficulty_modifier, time_context)
570
190x
        VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) RETURNING id
571
190x
    `
572
190x

573
190x
    var id int
574
190x
    err = s.db.QueryRowContext(ctx, query,
575
190x
        question.Type,
576
190x
        question.Language,
577
190x
        question.Level,
578
190x
        question.DifficultyScore,
579
190x
        string(contentJSON),
580
190x
        question.CorrectAnswer,
581
190x
        question.Explanation,
582
190x
        question.Status,
583
190x
        question.TopicCategory,
584
190x
        question.GrammarFocus,
585
190x
        question.VocabularyDomain,
586
190x
        question.Scenario,
587
190x
        question.StyleModifier,
588
190x
        question.DifficultyModifier,
589
190x
        question.TimeContext,
590
190x
    ).Scan(&id)
591
190x
    if err != nil {
592
        return contextutils.WrapError(err, "failed to save question to database")
593
    }
594

595
190x
    question.ID = id
596
190x
    return nil
597
}
598

599
// AssignQuestionToUser assigns a question to a user
600
195x
func (s *QuestionService) AssignQuestionToUser(ctx context.Context, questionID, userID int) (err error) {
601
195x
    ctx, span := observability.TraceQuestionFunction(ctx, "assign_question_to_user", observability.AttributeQuestionID(questionID), observability.AttributeUserID(userID))
602
195x
    defer func() {
603
195x
        if err != nil {
604
            span.RecordError(err, trace.WithStackTrace(true))
605
            span.SetStatus(codes.Error, err.Error())
606
        }
607
195x
        span.End()
608
    }()
609
195x
    query := `
610
195x
        INSERT INTO user_questions (user_id, question_id)
611
195x
        VALUES ($1, $2)
612
195x
        ON CONFLICT (user_id, question_id) DO NOTHING
613
195x
    `
614
195x
    _, err = s.db.ExecContext(ctx, query, userID, questionID)
615
195x
    return contextutils.WrapError(err, "failed to assign question to user")
616
}
617

618
// GetQuestionByID retrieves a question by its ID
619
14x
func (s *QuestionService) GetQuestionByID(ctx context.Context, id int) (result0 *models.Question, err error) {
620
14x
    ctx, span := observability.TraceQuestionFunction(ctx, "get_question_by_id", observability.AttributeQuestionID(id))
621
14x
    defer func() {
622
14x
        if err != nil {
623
1x
            span.RecordError(err, trace.WithStackTrace(true))
624
1x
            span.SetStatus(codes.Error, err.Error())
625
1x
        }
626
14x
        span.End()
627
    }()
628
14x
    query := fmt.Sprintf("SELECT %s FROM questions WHERE id = $1", questionSelectFields)
629
14x
    return s.getQuestionByQuery(ctx, query, id)
630
}
631

632
// GetQuestionWithStats retrieves a question by its ID with response statistics
633
2x
func (s *QuestionService) GetQuestionWithStats(ctx context.Context, id int) (result0 *QuestionWithStats, err error) {
634
2x
    ctx, span := observability.TraceQuestionFunction(ctx, "get_question_with_stats", observability.AttributeQuestionID(id))
635
2x
    defer func() {
636
2x
        if err != nil {
637
            span.RecordError(err, trace.WithStackTrace(true))
638
            span.SetStatus(codes.Error, err.Error())
639
        }
640
2x
        span.End()
641
    }()
642
2x
    query := `
643
2x
        SELECT
644
2x
            q.id, q.type, q.language, q.level, q.difficulty_score,
645
2x
            q.content, q.correct_answer, q.explanation, q.created_at, q.status,
646
2x
            q.topic_category, q.grammar_focus, q.vocabulary_domain, q.scenario, q.style_modifier, q.difficulty_modifier, q.time_context,
647
2x
            COALESCE(SUM(CASE WHEN ur.is_correct = true THEN 1 ELSE 0 END), 0) as correct_count,
648
2x
            COALESCE(SUM(CASE WHEN ur.is_correct = false THEN 1 ELSE 0 END), 0) as incorrect_count,
649
2x
            COALESCE(COUNT(ur.id), 0) as total_responses
650
2x
        FROM questions q
651
2x
        LEFT JOIN user_responses ur ON q.id = ur.question_id
652
2x
        WHERE q.id = $1
653
2x
        GROUP BY q.id, q.type, q.language, q.level, q.difficulty_score,
654
2x
                 q.content, q.correct_answer, q.explanation, q.created_at, q.status,
655
2x
                 q.topic_category, q.grammar_focus, q.vocabulary_domain, q.scenario, q.style_modifier, q.difficulty_modifier, q.time_context
656
2x
    `
657
2x

658
2x
    q := &models.Question{}
659
2x
    stats := &QuestionWithStats{Question: q}
660
2x

661
2x
    var contentJSON string
662
2x
    err = s.db.QueryRowContext(ctx, query, id).Scan(
663
2x
        &q.ID, &q.Type, &q.Language, &q.Level, &q.DifficultyScore,
664
2x
        &contentJSON, &q.CorrectAnswer, &q.Explanation, &q.CreatedAt, &q.Status,
665
2x
        &q.TopicCategory, &q.GrammarFocus, &q.VocabularyDomain, &q.Scenario, &q.StyleModifier, &q.DifficultyModifier, &q.TimeContext,
666
2x
        &stats.CorrectCount, &stats.IncorrectCount, &stats.TotalResponses,
667
2x
    )
668
2x
    if err != nil {
669
        if errors.Is(err, sql.ErrNoRows) {
670
            return nil, contextutils.ErrQuestionNotFound
671
        }
672
        return nil, contextutils.WrapError(err, "failed to get question with stats")
673
    }
674

675
    // Parse JSON content
676
2x
    if err := q.UnmarshalContentFromJSON(contentJSON); err != nil {
677
        return nil, contextutils.WrapError(err, "failed to unmarshal question content")
678
    }
679

680
2x
    return stats, nil
681
}
682

683
// GetQuestionsByFilter retrieves questions matching the specified criteria
684
8x
func (s *QuestionService) GetQuestionsByFilter(ctx context.Context, userID int, language, level string, questionType models.QuestionType, limit int) (result0 []models.Question, err error) {
685
8x
    ctx, span := observability.TraceQuestionFunction(ctx, "get_questions_by_filter", observability.AttributeUserID(userID), observability.AttributeLanguage(language), observability.AttributeLevel(level), observability.AttributeQuestionType(questionType))
686
8x
    defer func() {
687
8x
        if err != nil {
688
            span.RecordError(err, trace.WithStackTrace(true))
689
            span.SetStatus(codes.Error, err.Error())
690
        }
691
8x
        span.End()
692
    }()
693
8x
    var query string
694
8x
    var args []interface{}
695
8x

696
8x
    if questionType == "" {
697
3x
        // Don't filter by type if questionType is empty
698
3x
        query = `
699
3x
            SELECT q.id, q.type, q.language, q.level, q.difficulty_score, q.content, q.correct_answer, q.explanation, q.created_at, q.status
700
3x
            FROM questions q
701
3x
            JOIN user_questions uq ON q.id = uq.question_id
702
3x
            WHERE uq.user_id = $1 AND q.language = $2 AND q.level = $3 AND q.status = $4
703
3x
            ORDER BY RANDOM()
704
3x
            LIMIT $5
705
3x
        `
706
3x
        args = []interface{}{userID, language, level, models.QuestionStatusActive, limit}
707
3x
    } else {
708
5x
        // Filter by specific type
709
5x
        query = `
710
5x
            SELECT q.id, q.type, q.language, q.level, q.difficulty_score, q.content, q.correct_answer, q.explanation, q.created_at, q.status
711
5x
            FROM questions q
712
5x
            JOIN user_questions uq ON q.id = uq.question_id
713
5x
            WHERE uq.user_id = $1 AND q.language = $2 AND q.level = $3 AND q.type = $4 AND q.status = $5
714
5x
            ORDER BY RANDOM()
715
5x
            LIMIT $6
716
5x
        `
717
5x
        args = []interface{}{userID, language, level, questionType, models.QuestionStatusActive, limit}
718
5x
    }
719

720
8x
    rows, err := s.db.QueryContext(ctx, query, args...)
721
8x
    if err != nil {
722
        return nil, contextutils.WrapError(err, "failed to query questions by filter")
723
    }
724
8x
    defer func() {
725
8x
        if err := rows.Close(); err != nil {
726
            s.logger.Warn(ctx, "Failed to close rows", map[string]interface{}{"error": err.Error()})
727
        }
728
    }()
729

730
8x
    var questions []models.Question
731
8x
    for rows.Next() {
732
6x
        question, err := s.scanQuestionBasicFromRows(rows)
733
6x
        if err != nil {
734
            return nil, contextutils.WrapError(err, "failed to scan question from rows")
735
        }
736
6x
        questions = append(questions, *question)
737
    }
738

739
8x
    return questions, nil
740
}
741

742
// ReportedQuestionWithUser represents a reported question with user information
743
type ReportedQuestionWithUser struct {
744
    *models.Question
745
    ReportedByUsername string `json:"reported_by_username"`
746
    TotalResponses     int    `json:"total_responses"`
747
}
748

749
// GetReportedQuestions retrieves all questions that have been reported as problematic
750
1x
func (s *QuestionService) GetReportedQuestions(ctx context.Context) (result0 []*ReportedQuestionWithUser, err error) {
751
1x
    ctx, span := observability.TraceQuestionFunction(ctx, "get_reported_questions")
752
1x
    defer func() {
753
1x
        if err != nil {
754
            span.RecordError(err, trace.WithStackTrace(true))
755
            span.SetStatus(codes.Error, err.Error())
756
        }
757
1x
        span.End()
758
    }()
759
1x
    query := `
760
1x
        SELECT q.id, q.type, q.language, q.level, q.difficulty_score, q.content, q.correct_answer, q.explanation, q.created_at, q.status, u.username,
761
1x
               COALESCE(COUNT(ur.id), 0) as total_responses
762
1x
        FROM questions q
763
1x
        LEFT JOIN user_questions uq ON q.id = uq.question_id
764
1x
        LEFT JOIN users u ON uq.user_id = u.id
765
1x
        LEFT JOIN user_responses ur ON q.id = ur.question_id
766
1x
        WHERE q.status = $1
767
1x
        GROUP BY q.id, q.type, q.language, q.level, q.difficulty_score, q.content, q.correct_answer, q.explanation, q.created_at, q.status, u.username
768
1x
        ORDER BY q.created_at DESC
769
1x
    `
770
1x

771
1x
    var rows *sql.Rows
772
1x
    rows, err = s.db.QueryContext(ctx, query, models.QuestionStatusReported)
773
1x
    if err != nil {
774
        return nil, contextutils.WrapError(err, "failed to query reported questions")
775
    }
776
1x
    defer func() {
777
1x
        if err := rows.Close(); err != nil {
778
            s.logger.Warn(ctx, "Failed to close rows", map[string]interface{}{"error": err.Error()})
779
        }
780
    }()
781

782
1x
    var questions []*ReportedQuestionWithUser
783
1x
    for rows.Next() {
784
2x
        var question models.Question
785
2x
        var reportedByUsername sql.NullString
786
2x
        var contentJSON string
787
2x
        var totalResponses int
788
2x

789
2x
        err = rows.Scan(
790
2x
            &question.ID,
791
2x
            &question.Type,
792
2x
            &question.Language,
793
2x
            &question.Level,
794
2x
            &question.DifficultyScore,
795
2x
            &contentJSON,
796
2x
            &question.CorrectAnswer,
797
2x
            &question.Explanation,
798
2x
            &question.CreatedAt,
799
2x
            &question.Status,
800
2x
            &reportedByUsername,
801
2x
            &totalResponses,
802
2x
        )
803
2x
        if err != nil {
804
            return nil, err
805
        }
806

807
2x
        if err := question.UnmarshalContentFromJSON(contentJSON); err != nil {
808
            return nil, err
809
        }
810

811
2x
        username := ""
812
2x
        if reportedByUsername.Valid {
813
2x
            username = reportedByUsername.String
814
2x
        }
815

816
2x
        reportedQuestion := &ReportedQuestionWithUser{
817
2x
            Question:           &question,
818
2x
            ReportedByUsername: username,
819
2x
            TotalResponses:     totalResponses,
820
2x
        }
821
2x

822
2x
        questions = append(questions, reportedQuestion)
823
    }
824

825
1x
    return questions, nil
826
}
827

828
// MarkQuestionAsFixed marks a reported question as fixed and puts it back in rotation
829
1x
func (s *QuestionService) MarkQuestionAsFixed(ctx context.Context, questionID int) (err error) {
830
1x
    ctx, span := observability.TraceQuestionFunction(ctx, "mark_question_as_fixed", observability.AttributeQuestionID(questionID))
831
1x
    defer func() {
832
1x
        if err != nil {
833
            span.RecordError(err, trace.WithStackTrace(true))
834
            span.SetStatus(codes.Error, err.Error())
835
        }
836
1x
        span.End()
837
    }()
838

839
1x
    query := `UPDATE questions SET status = $1 WHERE id = $2`
840
1x
    var result sql.Result
841
1x
    result, err = s.db.ExecContext(ctx, query, models.QuestionStatusActive, questionID)
842
1x
    if err != nil {
843
        return contextutils.WrapError(err, "failed to mark question as fixed")
844
    }
845

846
    // Check if the question was actually updated
847
1x
    rowsAffected, err := result.RowsAffected()
848
1x
    if err != nil {
849
        return contextutils.WrapError(err, "failed to get rows affected")
850
    }
851

852
1x
    if rowsAffected == 0 {
853
        return contextutils.WrapErrorf(contextutils.ErrRecordNotFound, "question with ID %d not found", questionID)
854
    }
855

856
1x
    return nil
857
}
858

859
// UpdateQuestion updates a question's content, correct answer, and explanation
860
1x
func (s *QuestionService) UpdateQuestion(ctx context.Context, questionID int, content map[string]interface{}, correctAnswerIndex int, explanation string) (err error) {
861
1x
    ctx, span := observability.TraceQuestionFunction(ctx, "update_question", observability.AttributeQuestionID(questionID))
862
1x
    defer func() {
863
1x
        if err != nil {
864
            span.RecordError(err, trace.WithStackTrace(true))
865
            span.SetStatus(codes.Error, err.Error())
866
        }
867
1x
        span.End()
868
    }()
869
1x
    var contentJSON []byte
870
1x
    // Marshal provided content map via a temporary Question instance to reuse method
871
1x
    tempQ := &models.Question{Content: content}
872
1x
    contentJSONStr, err := tempQ.MarshalContentToJSON()
873
1x
    if err != nil {
874
        return contextutils.WrapError(err, "failed to marshal content JSON")
875
    }
876
1x
    contentJSON = []byte(contentJSONStr)
877
1x
    if err != nil {
878
        return contextutils.WrapError(err, "failed to marshal content JSON")
879
    }
880

881
1x
    query := `UPDATE questions SET content = $1, correct_answer = $2, explanation = $3 WHERE id = $4`
882
1x
    var result sql.Result
883
1x
    result, err = s.db.ExecContext(ctx, query, string(contentJSON), correctAnswerIndex, explanation, questionID)
884
1x
    if err != nil {
885
        return contextutils.WrapError(err, "failed to update question")
886
    }
887

888
    // Check if the question was actually updated
889
1x
    rowsAffected, err := result.RowsAffected()
890
1x
    if err != nil {
891
        return contextutils.WrapError(err, "failed to get rows affected")
892
    }
893

894
1x
    if rowsAffected == 0 {
895
        return contextutils.WrapErrorf(contextutils.ErrRecordNotFound, "question with ID %d not found", questionID)
896
    }
897

898
1x
    return nil
899
}
900

901
// DeleteQuestion permanently deletes a question from the database
902
1x
func (s *QuestionService) DeleteQuestion(ctx context.Context, questionID int) (err error) {
903
1x
    ctx, span := observability.TraceQuestionFunction(ctx, "delete_question", observability.AttributeQuestionID(questionID))
904
1x
    defer func() {
905
1x
        if err != nil {
906
            span.RecordError(err, trace.WithStackTrace(true))
907
            span.SetStatus(codes.Error, err.Error())
908
        }
909
1x
        span.End()
910
    }()
911
    // First, delete associated user responses
912
1x
    deleteResponsesQuery := `DELETE FROM user_responses WHERE question_id = $1`
913
1x
    _, err = s.db.ExecContext(ctx, deleteResponsesQuery, questionID)
914
1x
    if err != nil {
915
        return contextutils.WrapError(err, "failed to delete associated user responses")
916
    }
917

918
    // Then delete the question itself
919
1x
    deleteQuestionQuery := `DELETE FROM questions WHERE id = $1`
920
1x
    var result sql.Result
921
1x
    result, err = s.db.ExecContext(ctx, deleteQuestionQuery, questionID)
922
1x
    if err != nil {
923
        return contextutils.WrapError(err, "failed to delete question")
924
    }
925

926
    // Check if the question was actually deleted
927
1x
    rowsAffected, err := result.RowsAffected()
928
1x
    if err != nil {
929
        return contextutils.WrapError(err, "failed to get rows affected")
930
    }
931

932
1x
    if rowsAffected == 0 {
933
        return contextutils.WrapErrorf(contextutils.ErrRecordNotFound, "question with ID %d not found", questionID)
934
    }
935

936
1x
    return nil
937
}
938

939
// ReportQuestion marks a question as reported/problematic by a specific user
940
11x
func (s *QuestionService) ReportQuestion(ctx context.Context, questionID, userID int, reportReason string) (err error) {
941
11x
    ctx, span := observability.TraceQuestionFunction(ctx, "report_question", observability.AttributeQuestionID(questionID), observability.AttributeUserID(userID))
942
11x
    defer func() {
943
11x
        if err != nil {
944
            span.RecordError(err, trace.WithStackTrace(true))
945
            span.SetStatus(codes.Error, err.Error())
946
        }
947
11x
        span.End()
948
    }()
949

950
    // Start a transaction
951
11x
    tx, err := s.db.BeginTx(ctx, nil)
952
11x
    if err != nil {
953
        return contextutils.WrapError(err, "failed to begin transaction")
954
    }
955
11x
    defer func() {
956
11x
        if err != nil {
957
            if rollbackErr := tx.Rollback(); rollbackErr != nil {
958
                s.logger.Warn(ctx, "Failed to rollback transaction", map[string]interface{}{"error": rollbackErr.Error()})
959
            }
960
        }
961
    }()
962

963
    // Check if question exists first
964
11x
    var questionExists bool
965
11x
    err = tx.QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM questions WHERE id = $1)`, questionID).Scan(&questionExists)
966
11x
    if err != nil {
967
        return contextutils.WrapError(err, "failed to check if question exists")
968
    }
969
11x
    if !questionExists {
970
        return contextutils.WrapErrorf(contextutils.ErrRecordNotFound, "question with id %d not found", questionID)
971
    }
972

973
    // Update question status to reported
974
11x
    updateQuery := `UPDATE questions SET status = $1 WHERE id = $2`
975
11x
    var result sql.Result
976
11x
    result, err = tx.ExecContext(ctx, updateQuery, models.QuestionStatusReported, questionID)
977
11x
    if err != nil {
978
        return contextutils.WrapError(err, "failed to update question status")
979
    }
980

981
    // Check if the question was actually updated
982
11x
    rowsAffected, err := result.RowsAffected()
983
11x
    if err != nil {
984
        return contextutils.WrapError(err, "failed to get rows affected")
985
    }
986

987
11x
    if rowsAffected == 0 {
988
        return contextutils.WrapErrorf(contextutils.ErrRecordNotFound, "question with ID %d not found", questionID)
989
    }
990

991
    // Use provided report reason or default message
992
11x
    reason := reportReason
993
11x
    if reason == "" {
994
4x
        reason = "Question reported by user"
995
4x
    }
996

997
    // Create or update a report record: if the same user reports the same question again,
998
    // update the report_reason to the new value instead of doing nothing. Also update created_at
999
    // so admin views show the time of the latest report by that user.
1000
11x
    reportQuery := `INSERT INTO question_reports (question_id, reported_by_user_id, report_reason) VALUES ($1, $2, $3) ON CONFLICT (question_id, reported_by_user_id) DO UPDATE SET report_reason = EXCLUDED.report_reason, created_at = now()`
1001
11x
    _, err = tx.ExecContext(ctx, reportQuery, questionID, userID, reason)
1002
11x
    if err != nil {
1003
        return contextutils.WrapError(err, "failed to create question report")
1004
    }
1005

1006
    // Commit the transaction
1007
11x
    err = tx.Commit()
1008
11x
    if err != nil {
1009
        return contextutils.WrapError(err, "failed to commit transaction")
1010
    }
1011

1012
11x
    return nil
1013
}
1014

1015
// GetNextQuestion gets the next question for a user based on usage count and availability
1016
206x
func (s *QuestionService) GetNextQuestion(ctx context.Context, userID int, language, level string, qType models.QuestionType) (result0 *QuestionWithStats, err error) {
1017
206x
    ctx, span := observability.TraceQuestionFunction(ctx, "get_next_question", observability.AttributeUserID(userID), observability.AttributeLanguage(language), observability.AttributeLevel(level), observability.AttributeQuestionType(qType))
1018
206x
    defer func() {
1019
206x
        if err != nil {
1020
            span.RecordError(err, trace.WithStackTrace(true))
1021
            span.SetStatus(codes.Error, err.Error())
1022
        }
1023
206x
        span.End()
1024
    }()
1025
    // Use priority-based selection with stats included
1026
206x
    return s.getNextQuestionWithPriority(ctx, userID, language, level, qType)
1027
}
1028

1029
// getNextQuestionWithPriority implements priority-based question selection with stats
1030
206x
func (s *QuestionService) getNextQuestionWithPriority(ctx context.Context, userID int, language, level string, qType models.QuestionType) (result0 *QuestionWithStats, err error) {
1031
206x
    ctx, span := observability.TraceQuestionFunction(ctx, "get_next_question_with_priority", observability.AttributeUserID(userID), observability.AttributeLanguage(language), observability.AttributeLevel(level), observability.AttributeQuestionType(qType))
1032
206x
    defer func() {
1033
206x
        if err != nil {
1034
            span.RecordError(err, trace.WithStackTrace(true))
1035
            span.SetStatus(codes.Error, err.Error())
1036
        }
1037
206x
        span.End()
1038
    }()
1039
    // Get user preferences
1040
206x
    var prefs *models.UserLearningPreferences
1041
206x
    prefs, err = s.learningService.GetUserLearningPreferences(ctx, userID)
1042
206x
    if err != nil {
1043
        s.logger.Warn(ctx, "Failed to get user preferences", map[string]interface{}{"user_id": userID, "error": err.Error()})
1044
        // Fall back to default preferences
1045
        prefs = s.learningService.GetDefaultLearningPreferences()
1046
    }
1047

1048
    // Get available questions with priority scores and stats
1049
206x
    var questions []*QuestionWithStats
1050
206x
    questions, err = s.getAvailableQuestionsWithPriority(ctx, userID, language, level, qType, prefs)
1051
206x
    if err != nil {
1052
        return nil, contextutils.WrapError(err, "failed to get available questions")
1053
    }
1054

1055
206x
    if len(questions) == 0 {
1056
3x
        // Fallback: try to get a random global question and assign it to the user
1057
3x
        globalQ, err := s.GetRandomGlobalQuestionForUser(ctx, userID, language, level, qType)
1058
3x
        if err != nil {
1059
            return nil, contextutils.WrapError(err, "no personalized questions, and failed to get global fallback question")
1060
        }
1061
3x
        if globalQ != nil {
1062
2x
            return globalQ, nil
1063
2x
        }
1064
1x
        return nil, nil // No questions available at all
1065
    }
1066

1067
    // Apply FreshQuestionRatio logic (NEW)
1068
203x
    selectedQuestion, err := s.selectQuestionWithFreshnessRatio(questions, prefs.FreshQuestionRatio)
1069
203x
    if err != nil {
1070
        return nil, contextutils.WrapError(err, "failed to select question with freshness ratio")
1071
    }
1072

1073
    // Return the selected question with stats (already included)
1074
203x
    return selectedQuestion, nil
1075
}
1076

1077
// GetAdaptiveQuestionsForDaily selects multiple adaptive questions for daily assignments
1078
51x
func (s *QuestionService) GetAdaptiveQuestionsForDaily(ctx context.Context, userID int, language, level string, limit int) (result0 []*QuestionWithStats, err error) {
1079
51x
    ctx, span := observability.TraceQuestionFunction(ctx, "get_adaptive_questions_for_daily")
1080
51x
    defer func() {
1081
51x
        if err != nil {
1082
            span.RecordError(err, trace.WithStackTrace(true))
1083
            span.SetStatus(codes.Error, err.Error())
1084
        }
1085
51x
        span.End()
1086
    }()
1087

1088
    // Get user learning preferences
1089
51x
    prefs, err := s.learningService.GetUserLearningPreferences(ctx, userID)
1090
51x
    if err != nil {
1091
        s.logger.Warn(ctx, "Failed to get user learning preferences, using defaults", map[string]interface{}{
1092
            "user_id": userID, "error": err.Error(),
1093
        })
1094
        prefs = &models.UserLearningPreferences{
1095
            FreshQuestionRatio: 0.7,
1096
        }
1097
    }
1098

1099
51x
    var selectedQuestions []*QuestionWithStats
1100
51x
    selectedQuestionIDs := make(map[int]bool) // Track selected question IDs to prevent duplicates
1101
51x

1102
51x
    // Select questions across different types to provide variety
1103
51x
    questionTypes := []models.QuestionType{models.Vocabulary, models.FillInBlank, models.QuestionAnswer, models.ReadingComprehension}
1104
51x

1105
51x
    // Calculate how many questions to select from each type
1106
51x
    questionsPerType := limit / len(questionTypes)
1107
51x
    remainingQuestions := limit % len(questionTypes)
1108
51x

1109
51x
    for i, qType := range questionTypes {
1110
204x
        // Calculate how many questions to get for this type
1111
204x
        currentLimit := questionsPerType
1112
204x
        if i < remainingQuestions {
1113
44x
            currentLimit++ // Distribute remaining questions evenly
1114
44x
        }
1115

1116
204x
        if currentLimit == 0 {
1117
8x
            continue
1118
        }
1119

1120
        // Get available questions for DAILY with 2-day recent-correct exclusion
1121
196x
        questions, err := s.getAvailableQuestionsForDailyWithPriority(ctx, userID, language, level, qType, prefs)
1122
196x
        if err != nil {
1123
            s.logger.Warn(ctx, "Failed to get questions for type", map[string]interface{}{
1124
                "user_id": userID, "type": qType, "error": err.Error(),
1125
            })
1126
            continue
1127
        }
1128

1129
        // Filter out questions that have already been selected
1130
196x
        var availableQuestions []*QuestionWithStats
1131
196x
        for _, q := range questions {
1132
933x
            if !selectedQuestionIDs[q.ID] {
1133
933x
                availableQuestions = append(availableQuestions, q)
1134
933x
            }
1135
        }
1136

1137
196x
        if len(availableQuestions) == 0 {
1138
25x
            // Try to get a global fallback question for this type
1139
25x
            globalQ, err := s.GetRandomGlobalQuestionForUser(ctx, userID, language, level, qType)
1140
25x
            if err != nil {
1141
                s.logger.Warn(ctx, "Failed to get global fallback question", map[string]interface{}{
1142
                    "user_id": userID, "type": qType, "error": err.Error(),
1143
                })
1144
                continue
1145
            }
1146
25x
            if globalQ != nil && !selectedQuestionIDs[globalQ.ID] {
1147
                selectedQuestions = append(selectedQuestions, globalQ)
1148
                selectedQuestionIDs[globalQ.ID] = true
1149
                s.logger.Info(ctx, "Added global fallback question", map[string]interface{}{
1150
                    "user_id": userID, "type": qType, "question_id": globalQ.ID,
1151
                })
1152
            }
1153
25x
            continue
1154
        }
1155

1156
        // Select questions for this type using adaptive selection
1157
171x
        s.logger.Info(ctx, "Starting selection for question type", map[string]interface{}{
1158
171x
            "user_id": userID, "type": qType, "current_limit": currentLimit, "available_questions": len(availableQuestions),
1159
171x
        })
1160
171x

1161
171x
        questionsSelected := 0
1162
171x
        remainingQuestionsForType := availableQuestions
1163
171x

1164
171x
        for j := 0; j < currentLimit && len(remainingQuestionsForType) > 0; j++ {
1165
694x
            // Apply freshness ratio logic for each selection
1166
694x
            selectedQuestion, err := s.selectQuestionWithFreshnessRatio(remainingQuestionsForType, prefs.FreshQuestionRatio)
1167
694x
            if err != nil {
1168
                s.logger.Warn(ctx, "Failed to select question with freshness ratio", map[string]interface{}{
1169
                    "user_id": userID, "type": qType, "error": err.Error(),
1170
                })
1171
                // Fallback to simple random selection
1172
                if len(remainingQuestionsForType) > 0 {
1173
                    selectedQuestion = remainingQuestionsForType[rand.Intn(len(remainingQuestionsForType))]
1174
                } else {
1175
                    break
1176
                }
1177
            }
1178

1179
694x
            if selectedQuestion != nil && !selectedQuestionIDs[selectedQuestion.ID] {
1180
694x
                selectedQuestions = append(selectedQuestions, selectedQuestion)
1181
694x
                selectedQuestionIDs[selectedQuestion.ID] = true
1182
694x
                questionsSelected++
1183
694x

1184
694x
                // Remove the selected question from the remaining pool
1185
694x
                var newRemainingQuestions []*QuestionWithStats
1186
694x
                for _, q := range remainingQuestionsForType {
1187
4180x
                    if q.ID != selectedQuestion.ID {
1188
3486x
                        newRemainingQuestions = append(newRemainingQuestions, q)
1189
3486x
                    }
1190
                }
1191
694x
                remainingQuestionsForType = newRemainingQuestions
1192
694x

1193
694x
                s.logger.Info(ctx, "Successfully selected question", map[string]interface{}{
1194
694x
                    "user_id": userID, "type": qType, "iteration": j, "question_id": selectedQuestion.ID,
1195
694x
                    "total_selected": len(selectedQuestions),
1196
694x
                })
1197
            } else {
1198
                s.logger.Warn(ctx, "Failed to select question for type", map[string]interface{}{
1199
                    "user_id": userID, "type": qType, "iteration": j, "current_limit": currentLimit,
1200
                    "selected_question_nil": selectedQuestion == nil,
1201
                    "already_selected":      selectedQuestion != nil && selectedQuestionIDs[selectedQuestion.ID],
1202
                })
1203
                // Remove the question from the pool even if it was already selected
1204
                if selectedQuestion != nil {
1205
                    var newRemainingQuestions []*QuestionWithStats
1206
                    for _, q := range remainingQuestionsForType {
1207
                        if q.ID != selectedQuestion.ID {
1208
                            newRemainingQuestions = append(newRemainingQuestions, q)
1209
                        }
1210
                    }
1211
                    remainingQuestionsForType = newRemainingQuestions
1212
                }
1213
            }
1214
        }
1215

1216
        // If we didn't select enough questions for this type, try simple selection from all available questions
1217
171x
        if questionsSelected < currentLimit {
1218
56x
            s.logger.Info(ctx, "Using simple selection to fill remaining slots", map[string]interface{}{
1219
56x
                "user_id": userID, "type": qType, "questions_selected": questionsSelected, "current_limit": currentLimit,
1220
56x
            })
1221
56x

1222
56x
            // Get all questions for this type again and filter out already selected ones
1223
56x
            allQuestionsForType, err := s.getAvailableQuestionsForDailyWithPriority(ctx, userID, language, level, qType, prefs)
1224
56x
            if err == nil {
1225
56x
                for _, q := range allQuestionsForType {
1226
167x
                    if !selectedQuestionIDs[q.ID] && questionsSelected < currentLimit {
1227
                        selectedQuestions = append(selectedQuestions, q)
1228
                        selectedQuestionIDs[q.ID] = true
1229
                        questionsSelected++
1230
                    }
1231
                }
1232
            }
1233
        }
1234

1235
171x
        s.logger.Info(ctx, "Completed selection for question type", map[string]interface{}{
1236
171x
            "user_id": userID, "type": qType, "questions_selected": questionsSelected, "target": currentLimit,
1237
171x
        })
1238
    }
1239

1240
    // If we don't have enough questions, fill with random questions from any type
1241
51x
    if len(selectedQuestions) < limit {
1242
24x
        remainingNeeded := limit - len(selectedQuestions)
1243
24x
        s.logger.Info(ctx, "Not enough questions from type-based selection, using fallback", map[string]interface{}{
1244
24x
            "user_id": userID, "selected_count": len(selectedQuestions), "limit": limit, "remaining_needed": remainingNeeded,
1245
24x
        })
1246
24x

1247
24x
        // Get all available questions by trying each question type
1248
24x
        var allQuestions []*QuestionWithStats
1249
24x
        questionIDMap := make(map[int]bool) // Track seen question IDs to avoid duplicates
1250
24x

1251
24x
        for _, qType := range questionTypes {
1252
96x
            questions, err := s.getAvailableQuestionsForDailyWithPriority(ctx, userID, language, level, qType, prefs)
1253
96x
            if err == nil {
1254
96x
                for _, q := range questions {
1255
259x
                    if !questionIDMap[q.ID] && !selectedQuestionIDs[q.ID] {
1256
61x
                        allQuestions = append(allQuestions, q)
1257
61x
                        questionIDMap[q.ID] = true
1258
61x
                    }
1259
                }
1260
            }
1261
        }
1262

1263
24x
        s.logger.Info(ctx, "Fallback questions available", map[string]interface{}{
1264
24x
            "user_id": userID, "all_questions_count": len(allQuestions),
1265
24x
        })
1266
24x

1267
24x
        if len(allQuestions) > 0 {
1268
7x
            // Select random questions to fill the remaining slots
1269
7x
            for i := 0; i < remainingNeeded && i < len(allQuestions); i++ {
1270
18x
                selectedQuestion, err := s.selectQuestionWithFreshnessRatio(allQuestions, prefs.FreshQuestionRatio)
1271
18x
                if err != nil {
1272
                    s.logger.Warn(ctx, "Failed to select question with freshness ratio in fallback", map[string]interface{}{
1273
                        "user_id": userID, "error": err.Error(),
1274
                    })
1275
                    // Fallback to simple random selection
1276
                    if len(allQuestions) > 0 {
1277
                        selectedQuestion = allQuestions[rand.Intn(len(allQuestions))]
1278
                    } else {
1279
                        break
1280
                    }
1281
                }
1282

1283
18x
                if selectedQuestion != nil && !selectedQuestionIDs[selectedQuestion.ID] {
1284
18x
                    selectedQuestions = append(selectedQuestions, selectedQuestion)
1285
18x
                    selectedQuestionIDs[selectedQuestion.ID] = true
1286
18x

1287
18x
                    // Remove the selected question from the pool
1288
18x
                    var newAllQuestions []*QuestionWithStats
1289
18x
                    for _, q := range allQuestions {
1290
164x
                        if q.ID != selectedQuestion.ID {
1291
146x
                            newAllQuestions = append(newAllQuestions, q)
1292
146x
                        }
1293
                    }
1294
18x
                    allQuestions = newAllQuestions
1295
                } else if selectedQuestion != nil {
1296
                    // Remove the question from the pool even if it was already selected
1297
                    var newAllQuestions []*QuestionWithStats
1298
                    for _, q := range allQuestions {
1299
                        if q.ID != selectedQuestion.ID {
1300
                            newAllQuestions = append(newAllQuestions, q)
1301
                        }
1302
                    }
1303
                    allQuestions = newAllQuestions
1304
                }
1305
            }
1306
        }
1307
    }
1308

1309
    // Ensure we don't exceed the limit
1310
51x
    if len(selectedQuestions) > limit {
1311
        selectedQuestions = selectedQuestions[:limit]
1312
    }
1313

1314
    // Final duplicate check - this should never happen but provides extra safety
1315
51x
    finalSelectedQuestions := make([]*QuestionWithStats, 0, len(selectedQuestions))
1316
51x
    finalSelectedIDs := make(map[int]bool)
1317
51x

1318
51x
    for _, q := range selectedQuestions {
1319
712x
        if !finalSelectedIDs[q.ID] {
1320
712x
            finalSelectedQuestions = append(finalSelectedQuestions, q)
1321
712x
            finalSelectedIDs[q.ID] = true
1322
712x
        } else {
1323
            s.logger.Warn(ctx, "Duplicate question detected in final selection", map[string]interface{}{
1324
                "user_id": userID, "question_id": q.ID,
1325
            })
1326
        }
1327
    }
1328

1329
    // Interleave selected questions by type to avoid bias toward types that were
1330
    // selected earlier in the algorithm. This ensures that when callers slice the
1331
    // returned list (e.g., to meet a smaller goal), later types like
1332
    // ReadingComprehension are not systematically excluded.
1333
51x
    typeBuckets := make(map[models.QuestionType][]*QuestionWithStats)
1334
51x
    var typeOrder []models.QuestionType
1335
51x
    for _, q := range finalSelectedQuestions {
1336
712x
        if _, ok := typeBuckets[q.Type]; !ok {
1337
171x
            typeOrder = append(typeOrder, q.Type)
1338
171x
        }
1339
712x
        typeBuckets[q.Type] = append(typeBuckets[q.Type], q)
1340
    }
1341

1342
51x
    interleaved := make([]*QuestionWithStats, 0, len(finalSelectedQuestions))
1343
51x
    for len(interleaved) < len(finalSelectedQuestions) {
1344
210x
        added := false
1345
210x
        for _, t := range typeOrder {
1346
717x
            if len(typeBuckets[t]) > 0 {
1347
712x
                interleaved = append(interleaved, typeBuckets[t][0])
1348
712x
                typeBuckets[t] = typeBuckets[t][1:]
1349
712x
                added = true
1350
712x
                if len(interleaved) >= len(finalSelectedQuestions) {
1351
48x
                    break
1352
                }
1353
            }
1354
        }
1355
210x
        if !added {
1356
            break
1357
        }
1358
    }
1359
51x
    finalSelectedQuestions = interleaved
1360
51x

1361
51x
    s.logger.Info(ctx, "Selected adaptive questions for daily assignment", map[string]interface{}{
1362
51x
        "user_id":            userID,
1363
51x
        "language":           language,
1364
51x
        "level":              level,
1365
51x
        "requested_limit":    limit,
1366
51x
        "selected_count":     len(finalSelectedQuestions),
1367
51x
        "duplicates_removed": len(selectedQuestions) - len(finalSelectedQuestions),
1368
51x
    })
1369
51x

1370
51x
    return finalSelectedQuestions, nil
1371
}
1372

1373
// GetQuestionStats returns basic statistics about questions in the system
1374
1x
func (s *QuestionService) GetQuestionStats(ctx context.Context) (result0 map[string]interface{}, err error) {
1375
1x
    ctx, span := observability.TraceQuestionFunction(ctx, "get_question_stats")
1376
1x
    defer func() {
1377
1x
        if err != nil {
1378
            span.RecordError(err, trace.WithStackTrace(true))
1379
            span.SetStatus(codes.Error, err.Error())
1380
        }
1381
1x
        span.End()
1382
    }()
1383
1x
    stats := make(map[string]interface{})
1384
1x

1385
1x
    // Total questions
1386
1x
    var totalQuestions int
1387
1x
    err = s.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM questions").Scan(&totalQuestions)
1388
1x
    if err != nil {
1389
        return nil, contextutils.WrapError(err, "failed to get total questions count")
1390
    }
1391
1x
    stats["total_questions"] = totalQuestions
1392
1x

1393
1x
    // Questions by type
1394
1x
    typeQuery := `
1395
1x
        SELECT type, COUNT(*) as count
1396
1x
        FROM questions
1397
1x
        GROUP BY type
1398
1x
    `
1399
1x
    rows, err := s.db.QueryContext(ctx, typeQuery)
1400
1x
    if err != nil {
1401
        return nil, contextutils.WrapError(err, "failed to query questions by type")
1402
    }
1403
1x
    defer func() {
1404
1x
        if err := rows.Close(); err != nil {
1405
            s.logger.Warn(ctx, "Failed to close rows", map[string]interface{}{"error": err.Error()})
1406
        }
1407
    }()
1408

1409
1x
    questionsByType := make(map[string]int)
1410
1x
    for rows.Next() {
1411
2x
        var qType string
1412
2x
        var count int
1413
2x
        if err := rows.Scan(&qType, &count); err != nil {
1414
            return nil, contextutils.WrapError(err, "failed to scan question type count")
1415
        }
1416
2x
        questionsByType[qType] = count
1417
    }
1418
1x
    stats["questions_by_type"] = questionsByType
1419
1x

1420
1x
    // Questions by level
1421
1x
    levelQuery := `
1422
1x
        SELECT level, COUNT(*) as count
1423
1x
        FROM questions
1424
1x
        GROUP BY level
1425
1x
    `
1426
1x
    rows, err = s.db.QueryContext(ctx, levelQuery)
1427
1x
    if err != nil {
1428
        return nil, contextutils.WrapError(err, "failed to query questions by level")
1429
    }
1430
1x
    defer func() {
1431
1x
        if err := rows.Close(); err != nil {
1432
            s.logger.Warn(ctx, "Failed to close rows", map[string]interface{}{"error": err.Error()})
1433
        }
1434
    }()
1435

1436
1x
    questionsByLevel := make(map[string]int)
1437
1x
    for rows.Next() {
1438
1x
        var level string
1439
1x
        var count int
1440
1x
        if err := rows.Scan(&level, &count); err != nil {
1441
            return nil, err
1442
        }
1443
1x
        questionsByLevel[level] = count
1444
    }
1445
1x
    stats["questions_by_level"] = questionsByLevel
1446
1x

1447
1x
    return stats, nil
1448
}
1449

1450
// GetDetailedQuestionStats returns detailed statistics about questions
1451
1x
func (s *QuestionService) GetDetailedQuestionStats(ctx context.Context) (result0 map[string]interface{}, err error) {
1452
1x
    ctx, span := observability.TraceQuestionFunction(ctx, "get_detailed_question_stats")
1453
1x
    defer func() {
1454
1x
        if err != nil {
1455
            span.RecordError(err, trace.WithStackTrace(true))
1456
            span.SetStatus(codes.Error, err.Error())
1457
        }
1458
1x
        span.End()
1459
    }()
1460
1x
    stats := make(map[string]interface{})
1461
1x

1462
1x
    // Total questions
1463
1x
    var totalQuestions int
1464
1x
    err = s.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM questions").Scan(&totalQuestions)
1465
1x
    if err != nil {
1466
        return nil, err
1467
    }
1468
1x
    stats["total_questions"] = totalQuestions
1469
1x

1470
1x
    // Questions by language, level, and type combination
1471
1x
    detailQuery := `
1472
1x
        SELECT language, level, type, COUNT(*) as count
1473
1x
        FROM questions
1474
1x
        GROUP BY language, level, type
1475
1x
        ORDER BY language, level, type
1476
1x
    `
1477
1x
    rows, err := s.db.QueryContext(ctx, detailQuery)
1478
1x
    if err != nil {
1479
        return nil, err
1480
    }
1481
1x
    defer func() {
1482
1x
        if err := rows.Close(); err != nil {
1483
            s.logger.Warn(ctx, "Failed to close rows", map[string]interface{}{"error": err.Error()})
1484
        }
1485
    }()
1486

1487
    // Create nested structure: language -> level -> type -> count
1488
1x
    questionsByDetail := make(map[string]map[string]map[string]int)
1489
1x
    for rows.Next() {
1490
1x
        var language, level, qType string
1491
1x
        var count int
1492
1x
        if err := rows.Scan(&language, &level, &qType, &count); err != nil {
1493
            return nil, err
1494
        }
1495

1496
1x
        if questionsByDetail[language] == nil {
1497
1x
            questionsByDetail[language] = make(map[string]map[string]int)
1498
1x
        }
1499
1x
        if questionsByDetail[language][level] == nil {
1500
1x
            questionsByDetail[language][level] = make(map[string]int)
1501
1x
        }
1502
1x
        questionsByDetail[language][level][qType] = count
1503
    }
1504
1x
    stats["questions_by_detail"] = questionsByDetail
1505
1x

1506
1x
    // Questions by language
1507
1x
    languageQuery := `
1508
1x
        SELECT language, COUNT(*) as count
1509
1x
        FROM questions
1510
1x
        GROUP BY language
1511
1x
    `
1512
1x
    rows, err = s.db.QueryContext(ctx, languageQuery)
1513
1x
    if err != nil {
1514
        return nil, err
1515
    }
1516
1x
    defer func() {
1517
1x
        if err := rows.Close(); err != nil {
1518
            s.logger.Warn(ctx, "Failed to close rows", map[string]interface{}{"error": err.Error()})
1519
        }
1520
    }()
1521

1522
1x
    questionsByLanguage := make(map[string]int)
1523
1x
    for rows.Next() {
1524
1x
        var language string
1525
1x
        var count int
1526
1x
        if err := rows.Scan(&language, &count); err != nil {
1527
            return nil, err
1528
        }
1529
1x
        questionsByLanguage[language] = count
1530
    }
1531
1x
    stats["questions_by_language"] = questionsByLanguage
1532
1x

1533
1x
    // Questions by type
1534
1x
    typeQuery := `
1535
1x
        SELECT type, COUNT(*) as count
1536
1x
        FROM questions
1537
1x
        GROUP BY type
1538
1x
    `
1539
1x
    rows, err = s.db.QueryContext(ctx, typeQuery)
1540
1x
    if err != nil {
1541
        return nil, err
1542
    }
1543
1x
    defer func() {
1544
1x
        if err := rows.Close(); err != nil {
1545
            s.logger.Warn(ctx, "Failed to close rows", map[string]interface{}{"error": err.Error()})
1546
        }
1547
    }()
1548

1549
1x
    questionsByType := make(map[string]int)
1550
1x
    for rows.Next() {
1551
1x
        var qType string
1552
1x
        var count int
1553
1x
        if err := rows.Scan(&qType, &count); err != nil {
1554
            return nil, err
1555
        }
1556
1x
        questionsByType[qType] = count
1557
    }
1558
1x
    stats["questions_by_type"] = questionsByType
1559
1x

1560
1x
    // Questions by level
1561
1x
    levelQuery := `
1562
1x
        SELECT level, COUNT(*) as count
1563
1x
        FROM questions
1564
1x
        GROUP BY level
1565
1x
    `
1566
1x
    rows, err = s.db.QueryContext(ctx, levelQuery)
1567
1x
    if err != nil {
1568
        return nil, err
1569
    }
1570
1x
    defer func() {
1571
1x
        if err := rows.Close(); err != nil {
1572
            s.logger.Warn(ctx, "Failed to close rows", map[string]interface{}{"error": err.Error()})
1573
        }
1574
    }()
1575

1576
1x
    questionsByLevel := make(map[string]int)
1577
1x
    for rows.Next() {
1578
1x
        var level string
1579
1x
        var count int
1580
1x
        if err := rows.Scan(&level, &count); err != nil {
1581
            return nil, err
1582
        }
1583
1x
        questionsByLevel[level] = count
1584
    }
1585
1x
    stats["questions_by_level"] = questionsByLevel
1586
1x

1587
1x
    return stats, nil
1588
}
1589

1590
// GetRecentQuestionContentsForUser retrieves recent question contents for a user
1591
1x
func (s *QuestionService) GetRecentQuestionContentsForUser(ctx context.Context, userID, limit int) (result0 []string, err error) {
1592
1x
    ctx, span := observability.TraceQuestionFunction(ctx, "get_recent_question_contents_for_user", observability.AttributeUserID(userID), observability.AttributeLimit(limit))
1593
1x
    defer func() {
1594
1x
        if err != nil {
1595
            span.RecordError(err, trace.WithStackTrace(true))
1596
            span.SetStatus(codes.Error, err.Error())
1597
        }
1598
1x
        span.End()
1599
    }()
1600
1x
    query := `
1601
1x
        SELECT DISTINCT q.content
1602
1x
        FROM user_responses ur
1603
1x
        JOIN questions q ON ur.question_id = q.id
1604
1x
        JOIN user_questions uq ON q.id = uq.question_id
1605
1x
        WHERE ur.user_id = $1 AND uq.user_id = $2
1606
1x
        ORDER BY q.content DESC
1607
1x
        LIMIT $3
1608
1x
    `
1609
1x

1610
1x
    var rows *sql.Rows
1611
1x
    rows, err = s.db.QueryContext(ctx, query, userID, userID, limit)
1612
1x
    if err != nil {
1613
        return []string{}, err
1614
    }
1615
1x
    defer func() {
1616
1x
        if err := rows.Close(); err != nil {
1617
            s.logger.Warn(ctx, "Failed to close rows", map[string]interface{}{"error": err.Error()})
1618
        }
1619
    }()
1620

1621
1x
    var contents []string
1622
1x
    for rows.Next() {
1623
1x
        var content string
1624
1x
        if err := rows.Scan(&content); err != nil {
1625
            return []string{}, err
1626
        }
1627
1x
        contents = append(contents, content)
1628
    }
1629

1630
    // Ensure we always return an empty slice instead of nil
1631
1x
    if contents == nil {
1632
        contents = []string{}
1633
    }
1634

1635
1x
    return contents, nil
1636
}
1637

1638
// GetUserQuestions retrieves actual questions for a user (not just content)
1639
6x
func (s *QuestionService) GetUserQuestions(ctx context.Context, userID, limit int) (result0 []*models.Question, err error) {
1640
6x
    ctx, span := observability.TraceQuestionFunction(ctx, "get_user_questions", observability.AttributeUserID(userID), observability.AttributeLimit(limit))
1641
6x
    defer func() {
1642
6x
        if err != nil {
1643
            span.RecordError(err, trace.WithStackTrace(true))
1644
            span.SetStatus(codes.Error, err.Error())
1645
        }
1646
6x
        span.End()
1647
    }()
1648
6x
    query := `
1649
6x
        SELECT q.id, q.type, q.language, q.level, q.difficulty_score, q.content, q.correct_answer, q.explanation, q.created_at, q.status, q.topic_category, q.grammar_focus, q.vocabulary_domain, q.scenario, q.style_modifier, q.difficulty_modifier, q.time_context
1650
6x
        FROM questions q
1651
6x
        JOIN user_questions uq ON q.id = uq.question_id
1652
6x
        WHERE uq.user_id = $1
1653
6x
        ORDER BY q.created_at DESC
1654
6x
        LIMIT $2
1655
6x
    `
1656
6x

1657
6x
    var rows *sql.Rows
1658
6x
    rows, err = s.db.QueryContext(ctx, query, userID, limit)
1659
6x
    if err != nil {
1660
        return nil, err
1661
    }
1662
6x
    defer func() {
1663
6x
        if err := rows.Close(); err != nil {
1664
            s.logger.Warn(ctx, "Failed to close rows", map[string]interface{}{"error": err.Error()})
1665
        }
1666
    }()
1667

1668
6x
    var questions []*models.Question
1669
6x
    for rows.Next() {
1670
13x
        question, err := s.scanQuestionFromRows(rows)
1671
13x
        if err != nil {
1672
            return nil, err
1673
        }
1674
13x
        questions = append(questions, question)
1675
    }
1676

1677
6x
    return questions, nil
1678
}
1679

1680
// GetUserQuestionsWithStats retrieves questions for a user with response statistics
1681
4x
func (s *QuestionService) GetUserQuestionsWithStats(ctx context.Context, userID, limit int) (result0 []*QuestionWithStats, err error) {
1682
4x
    ctx, span := observability.TraceQuestionFunction(ctx, "get_user_questions_with_stats", observability.AttributeUserID(userID), observability.AttributeLimit(limit))
1683
4x
    defer func() {
1684
4x
        if err != nil {
1685
            span.RecordError(err, trace.WithStackTrace(true))
1686
            span.SetStatus(codes.Error, err.Error())
1687
        }
1688
4x
        span.End()
1689
    }()
1690
4x
    query := `
1691
4x
        SELECT
1692
4x
            q.id, q.type, q.language, q.level, q.difficulty_score,
1693
4x
            q.content, q.correct_answer, q.explanation, q.created_at, q.status,
1694
4x
            COALESCE(SUM(CASE WHEN ur.is_correct = true THEN 1 ELSE 0 END), 0) as correct_count,
1695
4x
            COALESCE(SUM(CASE WHEN ur.is_correct = false THEN 1 ELSE 0 END), 0) as incorrect_count,
1696
4x
            COALESCE(COUNT(ur.id), 0) as total_responses,
1697
4x
            COALESCE(uq_stats.user_count, 0) as user_count
1698
4x
        FROM questions q
1699
4x
        JOIN user_questions uq ON q.id = uq.question_id
1700
4x
        LEFT JOIN user_responses ur ON q.id = ur.question_id
1701
4x
        LEFT JOIN (
1702
4x
            SELECT
1703
4x
                question_id,
1704
4x
                COUNT(*) as user_count
1705
4x
            FROM user_questions
1706
4x
            GROUP BY question_id
1707
4x
        ) uq_stats ON q.id = uq_stats.question_id
1708
4x
        WHERE uq.user_id = $1
1709
4x
        GROUP BY q.id, q.type, q.language, q.level, q.difficulty_score,
1710
4x
            q.content, q.correct_answer, q.explanation, q.created_at, q.status,
1711
4x
            uq_stats.user_count
1712
4x
        ORDER BY q.created_at DESC
1713
4x
        LIMIT $2
1714
4x
    `
1715
4x

1716
4x
    rows, err := s.db.QueryContext(ctx, query, userID, limit)
1717
4x
    if err != nil {
1718
        return nil, err
1719
    }
1720
4x
    defer func() {
1721
4x
        if err := rows.Close(); err != nil {
1722
            s.logger.Warn(ctx, "Failed to close rows", map[string]interface{}{"error": err.Error()})
1723
        }
1724
    }()
1725

1726
4x
    var questions []*QuestionWithStats
1727
4x
    for rows.Next() {
1728
66x
        questionWithStats, err := s.scanQuestionWithStatsFromRows(rows)
1729
66x
        if err != nil {
1730
            return nil, err
1731
        }
1732
66x
        questions = append(questions, questionWithStats)
1733
    }
1734

1735
4x
    if err = rows.Err(); err != nil {
1736
        return nil, err
1737
    }
1738

1739
4x
    return questions, nil
1740
}
1741

1742
// QuestionWithStats represents a question with response statistics
1743
type QuestionWithStats struct {
1744
    *models.Question
1745
    CorrectCount   int `json:"correct_count"`
1746
    IncorrectCount int `json:"incorrect_count"`
1747
    TotalResponses int `json:"total_responses"`
1748
    // TimesAnswered tracks how many times THIS user answered the question (per-user)
1749
    TimesAnswered   int    `json:"times_answered"`
1750
    UserCount       int    `json:"user_count"`
1751
    Reporters       string `json:"reporters,omitempty"`
1752
    ReportReasons   string `json:"report_reasons,omitempty"`
1753
    ConfidenceLevel *int   `json:"confidence_level,omitempty"`
1754
}
1755

1756
// GetQuestionsPaginated retrieves questions with pagination and response statistics
1757
7x
func (s *QuestionService) GetQuestionsPaginated(ctx context.Context, userID, page, pageSize int, search, typeFilter, statusFilter string) (result0 []*QuestionWithStats, result1 int, err error) {
1758
7x
    ctx, span := observability.TraceQuestionFunction(ctx, "get_questions_paginated", observability.AttributeUserID(userID), observability.AttributePage(page), observability.AttributePageSize(pageSize), observability.AttributeSearch(search), observability.AttributeTypeFilter(typeFilter), observability.AttributeStatusFilter(statusFilter))
1759
7x
    defer func() {
1760
7x
        if err != nil {
1761
            span.RecordError(err, trace.WithStackTrace(true))
1762
            span.SetStatus(codes.Error, err.Error())
1763
        }
1764
7x
        span.End()
1765
    }()
1766

1767
    // Build WHERE clause with filters using parameterized queries
1768
7x
    whereConditions := []string{"uq.user_id = $1"}
1769
7x
    args := []interface{}{userID}
1770
7x
    argCount := 1
1771
7x

1772
7x
    // Add search filter
1773
7x
    if search != "" {
1774
1x
        argCount++
1775
1x
        whereConditions = append(whereConditions, fmt.Sprintf("(q.content::text ILIKE $%d OR q.explanation ILIKE $%d)", argCount, argCount))
1776
1x
        args = append(args, "%"+search+"%")
1777
1x
    }
1778

1779
    // Add type filter
1780
7x
    if typeFilter != "" {
1781
1x
        argCount++
1782
1x
        whereConditions = append(whereConditions, fmt.Sprintf("q.type = $%d", argCount))
1783
1x
        args = append(args, typeFilter)
1784
1x
    }
1785

1786
    // Add status filter
1787
7x
    if statusFilter != "" {
1788
1x
        argCount++
1789
1x
        whereConditions = append(whereConditions, fmt.Sprintf("q.status = $%d", argCount))
1790
1x
        args = append(args, statusFilter)
1791
1x
    }
1792

1793
    // Join all conditions
1794
7x
    whereClause := "WHERE " + strings.Join(whereConditions, " AND ")
1795
7x

1796
7x
    // First get the total count with filters
1797
7x
    countQuery := fmt.Sprintf("SELECT COUNT(*) FROM questions q JOIN user_questions uq ON q.id = uq.question_id %s", whereClause)
1798
7x
    var totalCount int
1799
7x
    err = s.db.QueryRowContext(ctx, countQuery, args...).Scan(&totalCount)
1800
7x
    if err != nil {
1801
        return nil, 0, err
1802
    }
1803

1804
    // Calculate offset
1805
7x
    offset := (page - 1) * pageSize
1806
7x

1807
7x
    // Build main query with pagination
1808
7x
    query := fmt.Sprintf(`
1809
7x
        SELECT
1810
7x
            q.id, q.type, q.language, q.level, q.difficulty_score,
1811
7x
            q.content, q.correct_answer, q.explanation, q.created_at, q.status,
1812
7x
            q.topic_category, q.grammar_focus, q.vocabulary_domain, q.scenario, q.style_modifier, q.difficulty_modifier, q.time_context,
1813
7x
            COALESCE(SUM(CASE WHEN ur.is_correct = true THEN 1 ELSE 0 END), 0) as correct_count,
1814
7x
            COALESCE(SUM(CASE WHEN ur.is_correct = false THEN 1 ELSE 0 END), 0) as incorrect_count,
1815
7x
            COALESCE(COUNT(ur.id), 0) as total_responses,
1816
7x
            COALESCE(uq_stats.user_count, 0) as user_count
1817
7x
        FROM questions q
1818
7x
        JOIN user_questions uq ON q.id = uq.question_id
1819
7x
        LEFT JOIN user_responses ur ON q.id = ur.question_id
1820
7x
        LEFT JOIN (
1821
7x
            SELECT
1822
7x
                question_id,
1823
7x
                COUNT(*) as user_count
1824
7x
            FROM user_questions
1825
7x
            GROUP BY question_id
1826
7x
        ) uq_stats ON q.id = uq_stats.question_id
1827
7x
        %s
1828
7x
        GROUP BY q.id, q.type, q.language, q.level, q.difficulty_score,
1829
7x
            q.content, q.correct_answer, q.explanation, q.created_at, q.status,
1830
7x
            q.topic_category, q.grammar_focus, q.vocabulary_domain, q.scenario, q.style_modifier, q.difficulty_modifier, q.time_context,
1831
7x
            uq_stats.user_count
1832
7x
        ORDER BY q.id DESC
1833
7x
        LIMIT $%d OFFSET $%d
1834
7x
    `, whereClause, argCount+1, argCount+2)
1835
7x

1836
7x
    // Add pagination parameters
1837
7x
    args = append(args, pageSize, offset)
1838
7x

1839
7x
    rows, err := s.db.QueryContext(ctx, query, args...)
1840
7x
    if err != nil {
1841
        return nil, 0, err
1842
    }
1843
7x
    defer func() {
1844
7x
        if err := rows.Close(); err != nil {
1845
            s.logger.Warn(ctx, "Failed to close rows", map[string]interface{}{"error": err.Error()})
1846
        }
1847
    }()
1848

1849
7x
    var questions []*QuestionWithStats
1850
7x
    for rows.Next() {
1851
67x
        questionWithStats, err := s.scanQuestionWithStatsAndAllFieldsFromRows(rows)
1852
67x
        if err != nil {
1853
            return nil, 0, err
1854
        }
1855
67x
        questions = append(questions, questionWithStats)
1856
    }
1857

1858
7x
    if err = rows.Err(); err != nil {
1859
        return nil, 0, err
1860
    }
1861

1862
7x
    return questions, totalCount, nil
1863
}
1864

1865
// PRIORITY-BASED QUESTION SELECTION METHODS
1866

1867
// getAvailableQuestionsWithPriority retrieves available questions with priority scores and stats
1868
206x
func (s *QuestionService) getAvailableQuestionsWithPriority(ctx context.Context, userID int, language, level string, qType models.QuestionType, _ *models.UserLearningPreferences) (result0 []*QuestionWithStats, err error) {
1869
206x
    ctx, span := observability.TraceQuestionFunction(ctx, "get_available_questions_with_priority", observability.AttributeUserID(userID), observability.AttributeLanguage(language), observability.AttributeLevel(level), observability.AttributeQuestionType(qType))
1870
206x
    defer func() {
1871
206x
        if err != nil {
1872
            span.RecordError(err, trace.WithStackTrace(true))
1873
            span.SetStatus(codes.Error, err.Error())
1874
        }
1875
206x
        span.End()
1876
    }()
1877
    // Build SQL query with priority scoring and stats
1878
206x
    query := `
1879
206x
        SELECT q.id, q.type, q.language, q.level, q.difficulty_score, q.content, q.correct_answer, q.explanation, q.created_at, q.status,
1880
206x
               q.topic_category, q.grammar_focus, q.vocabulary_domain, q.scenario, q.style_modifier, q.difficulty_modifier, q.time_context,
1881
206x
               COALESCE(qps.priority_score, 100.0) as priority_score,
1882
206x
               COALESCE(uq_stats.times_answered, 0) as times_answered,
1883
206x
               uq_stats.last_answered_at,
1884
206x
               COALESCE(stats.correct_count, 0) as correct_count,
1885
206x
               COALESCE(stats.incorrect_count, 0) as incorrect_count,
1886
206x
               COALESCE(stats.total_responses, 0) as total_responses,
1887
206x
               uqm.confidence_level
1888
206x
        FROM questions q
1889
206x
        JOIN user_questions uq ON q.id = uq.question_id
1890
206x
        LEFT JOIN question_priority_scores qps ON q.id = qps.question_id AND qps.user_id = $1
1891
206x
        LEFT JOIN (
1892
206x
            SELECT question_id,
1893
206x
                   COUNT(*) as times_answered,
1894
206x
                   MAX(created_at) as last_answered_at
1895
206x
            FROM user_responses
1896
206x
            WHERE user_id = $1
1897
206x
            GROUP BY question_id
1898
206x
        ) uq_stats ON q.id = uq_stats.question_id
1899
206x
        LEFT JOIN (
1900
206x
            SELECT
1901
206x
                question_id,
1902
206x
                COUNT(CASE WHEN is_correct = true THEN 1 END) as correct_count,
1903
206x
                COUNT(CASE WHEN is_correct = false THEN 1 END) as incorrect_count,
1904
206x
                COUNT(*) as total_responses
1905
206x
            FROM user_responses
1906
206x
            GROUP BY question_id
1907
206x
        ) stats ON q.id = stats.question_id
1908
206x
        LEFT JOIN user_question_metadata uqm ON q.id = uqm.question_id AND uqm.user_id = $1
1909
206x
        WHERE uq.user_id = $1
1910
206x
        AND q.language = $2
1911
206x
        AND q.level = $3
1912
206x
        AND q.type = $4
1913
206x
        AND q.status = 'active'
1914
206x
        AND q.id NOT IN (
1915
206x
            SELECT ur.question_id
1916
206x
            FROM user_responses ur
1917
206x
            WHERE ur.user_id = $1
1918
206x
              AND ur.created_at > NOW() - INTERVAL '1 hour'
1919
206x
        )
1920
206x
        -- Exclude questions where the user's last 3 responses were all correct within the last 90 days
1921
206x
        AND NOT EXISTS (
1922
206x
            SELECT 1 FROM (
1923
206x
                SELECT ur2.is_correct
1924
206x
                FROM user_responses ur2
1925
206x
                WHERE ur2.user_id = $1
1926
206x
                  AND ur2.question_id = q.id
1927
206x
                  AND ur2.created_at >= NOW() - INTERVAL '90 days'
1928
206x
                ORDER BY ur2.created_at DESC
1929
206x
                LIMIT 3
1930
206x
            ) recent_three
1931
206x
            WHERE (SELECT COUNT(*) FROM (
1932
206x
                SELECT 1 FROM (
1933
206x
                    SELECT ur3.is_correct
1934
206x
                    FROM user_responses ur3
1935
206x
                    WHERE ur3.user_id = $1
1936
206x
                      AND ur3.question_id = q.id
1937
206x
                      AND ur3.created_at >= NOW() - INTERVAL '90 days'
1938
206x
                    ORDER BY ur3.created_at DESC
1939
206x
                    LIMIT 3
1940
206x
                ) t WHERE t.is_correct = TRUE
1941
206x
            ) c) = 3
1942
206x
        )
1943
206x
        -- Exclude questions the user explicitly marked as known with max confidence (5)
1944
206x
        -- within the last 60 days (approx. 2 months)
1945
206x
        AND NOT EXISTS (
1946
206x
            SELECT 1 FROM user_question_metadata uqm2
1947
206x
            WHERE uqm2.user_id = $1
1948
206x
              AND uqm2.question_id = q.id
1949
206x
              AND uqm2.marked_as_known = TRUE
1950
206x
              AND uqm2.confidence_level = 5
1951
206x
              AND uqm2.marked_as_known_at >= NOW() - INTERVAL '60 days'
1952
206x
        )
1953
206x
        ORDER BY priority_score DESC, RANDOM()
1954
206x
        LIMIT 50
1955
206x
    `
1956
206x

1957
206x
    rows, err := s.db.QueryContext(ctx, query, userID, language, level, qType)
1958
206x
    if err != nil {
1959
        return nil, contextutils.WrapErrorf(contextutils.ErrDatabaseQuery, "failed to query questions: %w", err)
1960
    }
1961
206x
    defer func() {
1962
206x
        if err := rows.Close(); err != nil {
1963
            s.logger.Warn(ctx, "Failed to close rows", map[string]interface{}{"error": err.Error()})
1964
        }
1965
    }()
1966

1967
206x
    var questions []*QuestionWithStats
1968
206x
    for rows.Next() {
1969
2011x
        questionWithStats, err := s.scanQuestionWithPriorityAndStatsFromRows(rows)
1970
2011x
        if err != nil {
1971
            s.logger.Error(ctx, "Error scanning question", err, map[string]interface{}{})
1972
            continue // Skip malformed rows
1973
        }
1974
2011x
        questions = append(questions, questionWithStats)
1975
    }
1976

1977
206x
    return questions, nil
1978
}
1979

1980
// getAvailableQuestionsForDailyWithPriority applies daily-specific eligibility:
1981
// exclude questions answered correctly within the last 2 days for the user.
1982
348x
func (s *QuestionService) getAvailableQuestionsForDailyWithPriority(ctx context.Context, userID int, language, level string, qType models.QuestionType, _ *models.UserLearningPreferences) (result0 []*QuestionWithStats, err error) {
1983
348x
    ctx, span := observability.TraceQuestionFunction(ctx, "get_available_questions_for_daily_with_priority", observability.AttributeUserID(userID), observability.AttributeLanguage(language), observability.AttributeLevel(level), observability.AttributeQuestionType(qType))
1984
348x
    defer func() {
1985
348x
        if err != nil {
1986
            span.RecordError(err, trace.WithStackTrace(true))
1987
            span.SetStatus(codes.Error, err.Error())
1988
        }
1989
348x
        span.End()
1990
    }()
1991
348x
    avoidDays := s.getDailyRepeatAvoidDays()
1992
348x
    query := `
1993
348x
        SELECT q.id, q.type, q.language, q.level, q.difficulty_score, q.content, q.correct_answer, q.explanation, q.created_at, q.status,
1994
348x
               q.topic_category, q.grammar_focus, q.vocabulary_domain, q.scenario, q.style_modifier, q.difficulty_modifier, q.time_context,
1995
348x
               COALESCE(qps.priority_score, 100.0) as priority_score,
1996
348x
               COALESCE(uq_stats.times_answered, 0) as times_answered,
1997
348x
               uq_stats.last_answered_at,
1998
348x
               COALESCE(stats.correct_count, 0) as correct_count,
1999
348x
               COALESCE(stats.incorrect_count, 0) as incorrect_count,
2000
348x
               COALESCE(stats.total_responses, 0) as total_responses,
2001
348x
               uqm.confidence_level
2002
348x
        FROM questions q
2003
348x
        JOIN user_questions uq ON q.id = uq.question_id
2004
348x
        LEFT JOIN question_priority_scores qps ON q.id = qps.question_id AND qps.user_id = $1
2005
348x
        LEFT JOIN (
2006
348x
            SELECT question_id,
2007
348x
                   COUNT(*) as times_answered,
2008
348x
                   MAX(created_at) as last_answered_at
2009
348x
            FROM user_responses
2010
348x
            WHERE user_id = $1
2011
348x
            GROUP BY question_id
2012
348x
        ) uq_stats ON q.id = uq_stats.question_id
2013
348x
        LEFT JOIN (
2014
348x
            SELECT
2015
348x
                question_id,
2016
348x
                COUNT(CASE WHEN is_correct = true THEN 1 END) as correct_count,
2017
348x
                COUNT(CASE WHEN is_correct = false THEN 1 END) as incorrect_count,
2018
348x
                COUNT(*) as total_responses
2019
348x
            FROM user_responses
2020
348x
            GROUP BY question_id
2021
348x
        ) stats ON q.id = stats.question_id
2022
348x
        LEFT JOIN user_question_metadata uqm ON q.id = uqm.question_id AND uqm.user_id = $1
2023
348x
        WHERE uq.user_id = $1
2024
348x
        AND q.language = $2
2025
348x
        AND q.level = $3
2026
348x
        AND q.type = $4
2027
348x
        AND q.status = 'active'
2028
348x
        AND NOT EXISTS (
2029
348x
            SELECT 1
2030
348x
            FROM user_responses ur
2031
348x
            WHERE ur.user_id = $1
2032
348x
              AND ur.question_id = q.id
2033
348x
              AND ur.is_correct = TRUE
2034
348x
              AND ur.created_at >= NOW() - ($5 || ' days')::interval
2035
348x
        )
2036
348x
        -- Exclude questions the user marked as known with confidence 5 within last 60 days
2037
348x
        AND NOT EXISTS (
2038
348x
            SELECT 1 FROM user_question_metadata uqm2
2039
348x
            WHERE uqm2.user_id = $1
2040
348x
              AND uqm2.question_id = q.id
2041
348x
              AND uqm2.marked_as_known = TRUE
2042
348x
              AND uqm2.confidence_level = 5
2043
348x
              AND uqm2.marked_as_known_at >= NOW() - INTERVAL '60 days'
2044
348x
        )
2045
348x
        ORDER BY priority_score DESC, RANDOM()
2046
348x
        LIMIT 50
2047
348x
    `
2048
348x

2049
348x
    rows, err := s.db.QueryContext(ctx, query, userID, language, level, qType, avoidDays)
2050
348x
    if err != nil {
2051
        return nil, contextutils.WrapErrorf(contextutils.ErrDatabaseQuery, "failed to query questions (daily): %w", err)
2052
    }
2053
348x
    defer func() {
2054
348x
        if err := rows.Close(); err != nil {
2055
            s.logger.Warn(ctx, "Failed to close rows", map[string]interface{}{"error": err.Error()})
2056
        }
2057
    }()
2058

2059
348x
    var questions []*QuestionWithStats
2060
348x
    for rows.Next() {
2061
1359x
        questionWithStats, err := s.scanQuestionWithPriorityAndStatsFromRows(rows)
2062
1359x
        if err != nil {
2063
            s.logger.Error(ctx, "Error scanning question (daily)", err, map[string]interface{}{})
2064
            continue
2065
        }
2066
1359x
        questions = append(questions, questionWithStats)
2067
    }
2068

2069
348x
    return questions, nil
2070
}
2071

2072
// selectQuestionWithWeightedRandomness selects a question using weighted random selection
2073
915x
func (s *QuestionService) selectQuestionWithWeightedRandomness(questions []*QuestionWithStats) (result0 *QuestionWithStats, err error) {
2074
915x
    if len(questions) == 0 {
2075
        return nil, contextutils.WrapError(contextutils.ErrRecordNotFound, "no questions available")
2076
    }
2077

2078
    // Use weighted random selection based on usage count (lower = higher priority)
2079
915x
    totalWeight := 0.0
2080
915x
    for _, q := range questions {
2081
5369x
        // Prefer per-user times answered when available
2082
5369x
        usageCount := q.TotalResponses
2083
5369x
        if q.TimesAnswered >= 0 {
2084
5369x
            usageCount = q.TimesAnswered
2085
5369x
        }
2086
        // Lower usage count = higher weight
2087
5369x
        weight := 1.0 / (float64(usageCount) + 1.0)
2088
5369x
        totalWeight += weight
2089
    }
2090

2091
    // Handle edge case where all questions have zero weight or floating-point precision issues
2092
915x
    if totalWeight <= 0 {
2093
        // If all questions have equal weight (e.g., all TotalResponses = 0), use simple random selection
2094
        return questions[rand.Intn(len(questions))], nil
2095
    }
2096

2097
915x
    target := rand.Float64() * totalWeight
2098
915x
    currentWeight := 0.0
2099
915x

2100
915x
    for _, q := range questions {
2101
3063x
        usageCount := q.TotalResponses
2102
3063x
        if q.TimesAnswered >= 0 {
2103
3063x
            usageCount = q.TimesAnswered
2104
3063x
        }
2105
3063x
        weight := 1.0 / (float64(usageCount) + 1.0)
2106
3063x
        currentWeight += weight
2107
3063x
        if currentWeight >= target {
2108
915x
            return q, nil
2109
915x
        }
2110
    }
2111

2112
    // Fallback: if we reach the end without selecting (due to floating-point precision),
2113
    // return the last question or a random one
2114
    if len(questions) > 0 {
2115
        return questions[len(questions)-1], nil
2116
    }
2117

2118
    return nil, contextutils.WrapError(contextutils.ErrInternalError, "failed to select question with weighted randomness")
2119
}
2120

2121
// selectQuestionWithFreshnessRatio selects a question based on freshness ratio
2122
915x
func (s *QuestionService) selectQuestionWithFreshnessRatio(questions []*QuestionWithStats, freshnessRatio float64) (result0 *QuestionWithStats, err error) {
2123
915x
    if len(questions) == 0 {
2124
        return nil, contextutils.WrapError(contextutils.ErrRecordNotFound, "no questions available")
2125
    }
2126

2127
    // Separate fresh and review questions based on total responses
2128
915x
    var freshQuestions []*QuestionWithStats
2129
915x
    var reviewQuestions []*QuestionWithStats
2130
915x

2131
915x
    for _, q := range questions {
2132
6355x
        // Consider fresh relative to this user (TimesAnswered==0). Fall back to TotalResponses if TimesAnswered not set.
2133
6355x
        isFresh := false
2134
6355x
        if q.TimesAnswered >= 0 {
2135
6355x
            isFresh = q.TimesAnswered == 0
2136
6355x
        } else {
2137
            isFresh = q.TotalResponses == 0
2138
        }
2139
6355x
        if isFresh {
2140
4754x
            freshQuestions = append(freshQuestions, q)
2141
4754x
        } else {
2142
1601x
            reviewQuestions = append(reviewQuestions, q)
2143
1601x
        }
2144
    }
2145

2146
    // Use probabilistic selection based on the freshness ratio
2147
915x
    var selectedQuestions []*QuestionWithStats
2148
915x
    if len(freshQuestions) > 0 && len(reviewQuestions) > 0 {
2149
201x
        // Both categories available - use probabilistic selection
2150
201x
        if rand.Float64() < freshnessRatio {
2151
97x
            selectedQuestions = freshQuestions
2152
97x
        } else {
2153
104x
            selectedQuestions = reviewQuestions
2154
104x
        }
2155
714x
    } else if len(freshQuestions) > 0 {
2156
714x
        // Only fresh questions available
2157
714x
        selectedQuestions = freshQuestions
2158
714x
    } else if len(reviewQuestions) > 0 {
2159
        // Only review questions available
2160
        selectedQuestions = reviewQuestions
2161
    } else {
2162
        // Fallback to all questions if no separation possible
2163
        selectedQuestions = questions
2164
    }
2165

2166
915x
    if len(selectedQuestions) == 0 {
2167
        return nil, contextutils.WrapError(contextutils.ErrRecordNotFound, "no questions available after freshness filtering")
2168
    }
2169

2170
    // Use weighted random selection within the chosen category
2171
915x
    result, err := s.selectQuestionWithWeightedRandomness(selectedQuestions)
2172
915x
    if err != nil {
2173
        // Log debug info about the selection failure
2174
        s.logger.Warn(context.Background(), "selectQuestionWithWeightedRandomness failed", map[string]interface{}{
2175
            "total_questions":        len(questions),
2176
            "fresh_questions":        len(freshQuestions),
2177
            "review_questions":       len(reviewQuestions),
2178
            "selected_category_size": len(selectedQuestions),
2179
            "freshness_ratio":        freshnessRatio,
2180
            "error":                  err.Error(),
2181
        })
2182
    }
2183
915x
    return result, err
2184
}
2185

2186
// GetUserQuestionCount returns the total number of questions available for a user
2187
3x
func (s *QuestionService) GetUserQuestionCount(ctx context.Context, userID int) (result0 int, err error) {
2188
3x
    ctx, span := observability.TraceQuestionFunction(ctx, "get_user_question_count", observability.AttributeUserID(userID))
2189
3x
    defer func() {
2190
3x
        if err != nil {
2191
            span.RecordError(err, trace.WithStackTrace(true))
2192
            span.SetStatus(codes.Error, err.Error())
2193
        }
2194
3x
        span.End()
2195
    }()
2196
3x
    query := `
2197
3x
        SELECT COUNT(DISTINCT q.id)
2198
3x
        FROM questions q
2199
3x
        JOIN user_questions uq ON q.id = uq.question_id
2200
3x
        WHERE uq.user_id = $1 AND q.status = 'active'
2201
3x
    `
2202
3x

2203
3x
    var count int
2204
3x
    err = s.db.QueryRowContext(ctx, query, userID).Scan(&count)
2205
3x
    if err != nil {
2206
        return 0, contextutils.WrapErrorf(contextutils.ErrDatabaseQuery, "failed to get user question count: %w", err)
2207
    }
2208
3x
    return count, nil
2209
}
2210

2211
// GetUserResponseCount returns the total number of responses for a user
2212
3x
func (s *QuestionService) GetUserResponseCount(ctx context.Context, userID int) (result0 int, err error) {
2213
3x
    ctx, span := observability.TraceQuestionFunction(ctx, "get_user_response_count", observability.AttributeUserID(userID))
2214
3x
    defer func() {
2215
3x
        if err != nil {
2216
            span.RecordError(err, trace.WithStackTrace(true))
2217
            span.SetStatus(codes.Error, err.Error())
2218
        }
2219
3x
        span.End()
2220
    }()
2221
3x
    query := `SELECT COUNT(*) FROM user_responses WHERE user_id = $1`
2222
3x

2223
3x
    var count int
2224
3x
    err = s.db.QueryRowContext(ctx, query, userID).Scan(&count)
2225
3x
    if err != nil {
2226
        return 0, contextutils.WrapErrorf(contextutils.ErrDatabaseQuery, "failed to get user response count: %w", err)
2227
    }
2228
3x
    return count, nil
2229
}
2230

2231
// GetUsersForQuestion returns the users assigned to a question, up to 5 users, and the total count
2232
func (s *QuestionService) GetUsersForQuestion(ctx context.Context, questionID int) (result0 []*models.User, result1 int, err error) {
2233
    ctx, span := observability.TraceQuestionFunction(ctx, "get_users_for_question", observability.AttributeQuestionID(questionID))
2234
    defer func() {
2235
        if err != nil {
2236
            span.RecordError(err, trace.WithStackTrace(true))
2237
            span.SetStatus(codes.Error, err.Error())
2238
        }
2239
        span.End()
2240
    }()
2241

2242
    // First get the total count
2243
    countQuery := `SELECT COUNT(*) FROM user_questions WHERE question_id = $1`
2244
    var totalCount int
2245
    err = s.db.QueryRowContext(ctx, countQuery, questionID).Scan(&totalCount)
2246
    if err != nil {
2247
        return nil, 0, contextutils.WrapErrorf(contextutils.ErrDatabaseQuery, "failed to get user count for question: %w", err)
2248
    }
2249

2250
    // Then get up to 5 users
2251
    usersQuery := `
2252
        SELECT u.id, u.username, u.email, u.timezone, u.password_hash, u.last_active,
2253
               u.preferred_language, u.current_level, u.ai_provider, u.ai_model,
2254
               u.ai_enabled, u.ai_api_key, u.created_at, u.updated_at
2255
        FROM users u
2256
        JOIN user_questions uq ON u.id = uq.user_id
2257
        WHERE uq.question_id = $1
2258
        ORDER BY u.username
2259
        LIMIT 5
2260
    `
2261

2262
    rows, err := s.db.QueryContext(ctx, usersQuery, questionID)
2263
    if err != nil {
2264
        return nil, 0, contextutils.WrapErrorf(contextutils.ErrDatabaseQuery, "failed to get users for question: %w", err)
2265
    }
2266
    defer func() {
2267
        if err := rows.Close(); err != nil {
2268
            s.logger.Warn(ctx, "Failed to close rows", map[string]interface{}{"error": err.Error()})
2269
        }
2270
    }()
2271

2272
    var users []*models.User
2273
    for rows.Next() {
2274
        user := &models.User{}
2275
        err = rows.Scan(
2276
            &user.ID,
2277
            &user.Username,
2278
            &user.Email,
2279
            &user.Timezone,
2280
            &user.PasswordHash,
2281
            &user.LastActive,
2282
            &user.PreferredLanguage,
2283
            &user.CurrentLevel,
2284
            &user.AIProvider,
2285
            &user.AIModel,
2286
            &user.AIEnabled,
2287
            &user.AIAPIKey,
2288
            &user.CreatedAt,
2289
            &user.UpdatedAt,
2290
        )
2291
        if err != nil {
2292
            return nil, 0, contextutils.WrapErrorf(contextutils.ErrDatabaseQuery, "failed to scan user: %w", err)
2293
        }
2294
        users = append(users, user)
2295
    }
2296

2297
    if err = rows.Err(); err != nil {
2298
        return nil, 0, contextutils.WrapErrorf(contextutils.ErrDatabaseQuery, "error iterating users: %w", err)
2299
    }
2300

2301
    // Ensure we always return an empty slice instead of nil
2302
    if users == nil {
2303
        users = make([]*models.User, 0)
2304
    }
2305

2306
    return users, totalCount, nil
2307
}
2308

2309
// Helper: scan a *sql.Row into a QuestionWithStats (for single-row queries)
2310
38x
func (s *QuestionService) scanQuestionWithPriorityAndStatsFromRow(row *sql.Row) (result0 *QuestionWithStats, err error) {
2311
38x
    questionWithStats := &QuestionWithStats{
2312
38x
        Question: &models.Question{},
2313
38x
    }
2314
38x
    var contentJSON string
2315
38x
    var priorityScore float64
2316
38x
    var timesAnswered int
2317
38x
    var lastAnsweredAt sql.NullTime
2318
38x

2319
38x
    err = row.Scan(
2320
38x
        &questionWithStats.ID,
2321
38x
        &questionWithStats.Type,
2322
38x
        &questionWithStats.Language,
2323
38x
        &questionWithStats.Level,
2324
38x
        &questionWithStats.DifficultyScore,
2325
38x
        &contentJSON,
2326
38x
        &questionWithStats.CorrectAnswer,
2327
38x
        &questionWithStats.Explanation,
2328
38x
        &questionWithStats.CreatedAt,
2329
38x
        &questionWithStats.Status,
2330
38x
        &questionWithStats.TopicCategory,
2331
38x
        &questionWithStats.GrammarFocus,
2332
38x
        &questionWithStats.VocabularyDomain,
2333
38x
        &questionWithStats.Scenario,
2334
38x
        &questionWithStats.StyleModifier,
2335
38x
        &questionWithStats.DifficultyModifier,
2336
38x
        &questionWithStats.TimeContext,
2337
38x
        &priorityScore,
2338
38x
        &timesAnswered,
2339
38x
        &lastAnsweredAt,
2340
38x
        &questionWithStats.CorrectCount,
2341
38x
        &questionWithStats.IncorrectCount,
2342
38x
        &questionWithStats.TotalResponses,
2343
38x
    )
2344
38x
    if err != nil {
2345
31x
        return nil, err
2346
31x
    }
2347

2348
7x
    if err := questionWithStats.UnmarshalContentFromJSON(contentJSON); err != nil {
2349
        return nil, err
2350
    }
2351

2352
7x
    return questionWithStats, nil
2353
}
2354

2355
// GetRandomGlobalQuestionForUser finds a random question from the global pool for the given language, level, and type that is not already assigned to the user, assigns it, and returns it.
2356
38x
func (s *QuestionService) GetRandomGlobalQuestionForUser(ctx context.Context, userID int, language, level string, qType models.QuestionType) (result0 *QuestionWithStats, err error) {
2357
38x
    ctx, span := observability.TraceQuestionFunction(ctx, "get_random_global_question_for_user", observability.AttributeUserID(userID), observability.AttributeLanguage(language), observability.AttributeLevel(level), observability.AttributeQuestionType(qType))
2358
38x
    defer func() {
2359
38x
        if err != nil {
2360
            span.RecordError(err, trace.WithStackTrace(true))
2361
            span.SetStatus(codes.Error, err.Error())
2362
        }
2363
38x
        span.End()
2364
    }()
2365

2366
38x
    query := `
2367
38x
        SELECT q.id, q.type, q.language, q.level, q.difficulty_score, q.content, q.correct_answer, q.explanation, q.created_at, q.status,
2368
38x
               q.topic_category, q.grammar_focus, q.vocabulary_domain, q.scenario, q.style_modifier, q.difficulty_modifier, q.time_context,
2369
38x
               100.0 as priority_score, 0 as times_answered, NULL as last_answered_at, 0 as correct_count, 0 as incorrect_count, 0 as total_responses
2370
38x
        FROM questions q
2371
38x
        WHERE q.language = $1
2372
38x
          AND q.level = $2
2373
38x
        AND q.type = $3
2374
38x
          AND q.status = 'active'
2375
38x
          AND q.id NOT IN (
2376
38x
            SELECT uq.question_id
2377
38x
            FROM user_questions uq
2378
38x
            WHERE uq.user_id = $4
2379
38x
          )
2380
38x
          -- Exclude questions the user marked as known with confidence 5 within last 60 days
2381
38x
          AND NOT EXISTS (
2382
38x
            SELECT 1 FROM user_question_metadata uqm2
2383
38x
            WHERE uqm2.user_id = $4
2384
38x
              AND uqm2.question_id = q.id
2385
38x
              AND uqm2.marked_as_known = TRUE
2386
38x
              AND uqm2.confidence_level = 5
2387
38x
              AND uqm2.marked_as_known_at >= NOW() - INTERVAL '60 days'
2388
38x
          )
2389
38x
        ORDER BY RANDOM()
2390
38x
        LIMIT 1
2391
38x
    `
2392
38x

2393
38x
    row := s.db.QueryRowContext(ctx, query, language, level, qType, userID)
2394
38x
    questionWithStats, err := s.scanQuestionWithPriorityAndStatsFromRow(row)
2395
38x
    if err != nil {
2396
31x
        if errors.Is(err, sql.ErrNoRows) {
2397
31x
            return nil, nil // No global questions available
2398
31x
        }
2399
        return nil, err
2400
    }
2401

2402
    // Assign the question to the user
2403
7x
    err = s.AssignQuestionToUser(ctx, questionWithStats.ID, userID)
2404
7x
    if err != nil {
2405
        s.logger.Warn(ctx, "Failed to assign global question to user", map[string]interface{}{"question_id": questionWithStats.ID, "user_id": userID, "error": err.Error()})
2406
        // Still return the question, but log the error
2407
    }
2408

2409
7x
    return questionWithStats, nil
2410
}
2411

2412
// GetAllQuestionsPaginated returns all questions with pagination and filtering
2413
1x
func (s *QuestionService) GetAllQuestionsPaginated(ctx context.Context, page, pageSize int, search, typeFilter, statusFilter, languageFilter, levelFilter string, userID *int) (result0 []*QuestionWithStats, result1 int, err error) {
2414
1x
    ctx, span := observability.TraceQuestionFunction(ctx, "get_all_questions_paginated")
2415
1x
    defer func() {
2416
1x
        if err != nil {
2417
            span.RecordError(err, trace.WithStackTrace(true))
2418
            span.SetStatus(codes.Error, err.Error())
2419
        }
2420
1x
        span.End()
2421
    }()
2422

2423
    // Build the base query
2424
1x
    baseQuery := `
2425
1x
        SELECT q.id, q.type, q.language, q.level, q.difficulty_score, q.content, q.correct_answer, q.explanation, q.created_at, q.status,
2426
1x
               q.topic_category, q.grammar_focus, q.vocabulary_domain, q.scenario, q.style_modifier, q.difficulty_modifier, q.time_context,
2427
1x
               COALESCE(ur_stats.correct_count, 0) as correct_count,
2428
1x
               COALESCE(ur_stats.incorrect_count, 0) as incorrect_count,
2429
1x
               COALESCE(ur_stats.total_responses, 0) as total_responses,
2430
1x
               COALESCE(uq_stats.user_count, 0) as user_count
2431
1x
        FROM questions q
2432
1x
        LEFT JOIN (
2433
1x
            SELECT
2434
1x
                question_id,
2435
1x
                COUNT(CASE WHEN is_correct = true THEN 1 END) as correct_count,
2436
1x
                COUNT(CASE WHEN is_correct = false THEN 1 END) as incorrect_count,
2437
1x
                COUNT(*) as total_responses
2438
1x
            FROM user_responses
2439
1x
            GROUP BY question_id
2440
1x
        ) ur_stats ON q.id = ur_stats.question_id
2441
1x
        LEFT JOIN (
2442
1x
            SELECT
2443
1x
                question_id,
2444
1x
                COUNT(*) as user_count
2445
1x
            FROM user_questions
2446
1x
            GROUP BY question_id
2447
1x
        ) uq_stats ON q.id = uq_stats.question_id
2448
1x
        WHERE 1=1
2449
1x
    `
2450
1x

2451
1x
    // Build the count query
2452
1x
    countQuery := `
2453
1x
        SELECT COUNT(*)
2454
1x
        FROM questions q
2455
1x
        WHERE 1=1
2456
1x
    `
2457
1x

2458
1x
    var args []interface{}
2459
1x
    argIndex := 1
2460
1x

2461
1x
    // Add filters
2462
1x
    if search != "" {
2463
        searchCondition := ` AND (q.content::text ILIKE $` + strconv.Itoa(argIndex) + ` OR q.explanation ILIKE $` + strconv.Itoa(argIndex) + `)`
2464
        baseQuery += searchCondition
2465
        countQuery += searchCondition
2466
        args = append(args, "%"+search+"%")
2467
        argIndex++
2468
    }
2469

2470
1x
    if typeFilter != "" {
2471
        typeCondition := ` AND q.type = $` + strconv.Itoa(argIndex)
2472
        baseQuery += typeCondition
2473
        countQuery += typeCondition
2474
        args = append(args, typeFilter)
2475
        argIndex++
2476
    }
2477

2478
1x
    if statusFilter != "" {
2479
        statusCondition := ` AND q.status = $` + strconv.Itoa(argIndex)
2480
        baseQuery += statusCondition
2481
        countQuery += statusCondition
2482
        args = append(args, statusFilter)
2483
        argIndex++
2484
    }
2485

2486
1x
    if languageFilter != "" {
2487
1x
        languageCondition := ` AND q.language = $` + strconv.Itoa(argIndex)
2488
1x
        baseQuery += languageCondition
2489
1x
        countQuery += languageCondition
2490
1x
        args = append(args, languageFilter)
2491
1x
        argIndex++
2492
1x
    }
2493

2494
1x
    if levelFilter != "" {
2495
1x
        levelCondition := ` AND q.level = $` + strconv.Itoa(argIndex)
2496
1x
        baseQuery += levelCondition
2497
1x
        countQuery += levelCondition
2498
1x
        args = append(args, levelFilter)
2499
1x
        argIndex++
2500
1x
    }
2501

2502
1x
    if userID != nil {
2503
        userCondition := ` AND q.id IN (SELECT question_id FROM user_questions WHERE user_id = $` + strconv.Itoa(argIndex) + `)`
2504
        baseQuery += userCondition
2505
        countQuery += userCondition
2506
        args = append(args, *userID)
2507
        argIndex++
2508
    }
2509

2510
    // Get total count
2511
1x
    var total int
2512
1x
    err = s.db.QueryRowContext(ctx, countQuery, args...).Scan(&total)
2513
1x
    if err != nil {
2514
        return nil, 0, contextutils.WrapErrorf(contextutils.ErrDatabaseQuery, "failed to get total count: %w", err)
2515
    }
2516

2517
    // Add pagination
2518
1x
    offset := (page - 1) * pageSize
2519
1x
    baseQuery += ` ORDER BY q.created_at DESC LIMIT $` + strconv.Itoa(argIndex) + ` OFFSET $` + strconv.Itoa(argIndex+1)
2520
1x
    args = append(args, pageSize, offset)
2521
1x

2522
1x
    // Execute the main query
2523
1x
    rows, err := s.db.QueryContext(ctx, baseQuery, args...)
2524
1x
    if err != nil {
2525
        return nil, 0, contextutils.WrapErrorf(contextutils.ErrDatabaseQuery, "failed to get questions: %w", err)
2526
    }
2527
1x
    defer func() {
2528
1x
        if closeErr := rows.Close(); closeErr != nil {
2529
            s.logger.Warn(ctx, "Warning: failed to close rows", map[string]interface{}{"error": closeErr.Error()})
2530
        }
2531
    }()
2532

2533
1x
    var questions []*QuestionWithStats
2534
1x
    for rows.Next() {
2535
        question, err := s.scanQuestionWithStatsAndAllFieldsFromRows(rows)
2536
        if err != nil {
2537
            return nil, 0, err
2538
        }
2539
        questions = append(questions, question)
2540
    }
2541

2542
1x
    return questions, total, nil
2543
}
2544

2545
// GetReportedQuestionsPaginated returns reported questions with pagination and filtering
2546
13x
func (s *QuestionService) GetReportedQuestionsPaginated(ctx context.Context, page, pageSize int, search, typeFilter, languageFilter, levelFilter string) (result0 []*QuestionWithStats, result1 int, err error) {
2547
13x
    ctx, span := observability.TraceQuestionFunction(ctx, "get_reported_questions_paginated")
2548
13x
    defer func() {
2549
13x
        if err != nil {
2550
            span.RecordError(err, trace.WithStackTrace(true))
2551
            span.SetStatus(codes.Error, err.Error())
2552
        }
2553
13x
        span.End()
2554
    }()
2555

2556
    // Validate pagination parameters
2557
13x
    if page < 1 {
2558
1x
        page = 1
2559
1x
    }
2560
13x
    if pageSize < 1 {
2561
1x
        pageSize = 10
2562
1x
    }
2563

2564
    // Build WHERE clause with filters using parameterized queries
2565
13x
    whereConditions := []string{"q.status = 'reported'"}
2566
13x
    args := []interface{}{}
2567
13x
    argCount := 0
2568
13x

2569
13x
    // Add search filter
2570
13x
    if search != "" {
2571
2x
        argCount++
2572
2x
        whereConditions = append(whereConditions, fmt.Sprintf("(q.content::text ILIKE $%d OR q.explanation ILIKE $%d)", argCount, argCount))
2573
2x
        args = append(args, "%"+search+"%")
2574
2x
    }
2575

2576
    // Add type filter
2577
13x
    if typeFilter != "" {
2578
2x
        argCount++
2579
2x
        whereConditions = append(whereConditions, fmt.Sprintf("q.type = $%d", argCount))
2580
2x
        args = append(args, typeFilter)
2581
2x
    }
2582

2583
    // Add language filter
2584
13x
    if languageFilter != "" {
2585
2x
        argCount++
2586
2x
        whereConditions = append(whereConditions, fmt.Sprintf("q.language = $%d", argCount))
2587
2x
        args = append(args, languageFilter)
2588
2x
    }
2589

2590
    // Add level filter
2591
13x
    if levelFilter != "" {
2592
2x
        argCount++
2593
2x
        whereConditions = append(whereConditions, fmt.Sprintf("q.level = $%d", argCount))
2594
2x
        args = append(args, levelFilter)
2595
2x
    }
2596

2597
    // Join all conditions
2598
13x
    whereClause := "WHERE " + strings.Join(whereConditions, " AND ")
2599
13x

2600
13x
    // Build the count query
2601
13x
    countQuery := fmt.Sprintf("SELECT COUNT(DISTINCT q.id) FROM questions q %s", whereClause)
2602
13x
    var total int
2603
13x
    err = s.db.QueryRowContext(ctx, countQuery, args...).Scan(&total)
2604
13x
    if err != nil {
2605
        return nil, 0, contextutils.WrapErrorf(contextutils.ErrDatabaseQuery, "failed to get total count: %w", err)
2606
    }
2607

2608
    // Calculate offset
2609
13x
    offset := (page - 1) * pageSize
2610
13x

2611
13x
    // Build main query with pagination
2612
13x
    query := fmt.Sprintf(`
2613
13x
        SELECT q.id, q.type, q.language, q.level, q.difficulty_score, q.content, q.correct_answer, q.explanation, q.created_at, q.status,
2614
13x
               q.topic_category, q.grammar_focus, q.vocabulary_domain, q.scenario, q.style_modifier, q.difficulty_modifier, q.time_context,
2615
13x
               COALESCE(ur_stats.correct_count, 0) as correct_count,
2616
13x
               COALESCE(ur_stats.incorrect_count, 0) as incorrect_count,
2617
13x
               COALESCE(ur_stats.total_responses, 0) as total_responses,
2618
13x
               STRING_AGG(DISTINCT u.username, ', ') as reporters,
2619
13x
               STRING_AGG(DISTINCT qr.report_reason, ' | ') as report_reasons
2620
13x
        FROM questions q
2621
13x
        LEFT JOIN (
2622
13x
            SELECT
2623
13x
                question_id,
2624
13x
                COUNT(CASE WHEN is_correct = true THEN 1 END) as correct_count,
2625
13x
                COUNT(CASE WHEN is_correct = false THEN 1 END) as incorrect_count,
2626
13x
                COUNT(*) as total_responses
2627
13x
            FROM user_responses
2628
13x
            GROUP BY question_id
2629
13x
        ) ur_stats ON q.id = ur_stats.question_id
2630
13x
        LEFT JOIN question_reports qr ON q.id = qr.question_id
2631
13x
        LEFT JOIN users u ON qr.reported_by_user_id = u.id
2632
13x
        %s
2633
13x
        GROUP BY q.id, q.type, q.language, q.level, q.difficulty_score, q.content, q.correct_answer, q.explanation, q.created_at, q.status,
2634
13x
                 q.topic_category, q.grammar_focus, q.vocabulary_domain, q.scenario, q.style_modifier, q.difficulty_modifier, q.time_context,
2635
13x
                 ur_stats.correct_count, ur_stats.incorrect_count, ur_stats.total_responses
2636
13x
        ORDER BY q.created_at DESC
2637
13x
        LIMIT $%d OFFSET $%d
2638
13x
    `, whereClause, argCount+1, argCount+2)
2639
13x

2640
13x
    // Add pagination parameters
2641
13x
    args = append(args, pageSize, offset)
2642
13x

2643
13x
    // Execute the main query
2644
13x
    rows, err := s.db.QueryContext(ctx, query, args...)
2645
13x
    if err != nil {
2646
        return nil, 0, contextutils.WrapErrorf(contextutils.ErrDatabaseQuery, "failed to get reported questions: %w", err)
2647
    }
2648
13x
    defer func() {
2649
13x
        if closeErr := rows.Close(); closeErr != nil {
2650
            s.logger.Warn(ctx, "Warning: failed to close rows", map[string]interface{}{"error": closeErr.Error()})
2651
        }
2652
    }()
2653

2654
13x
    var questions []*QuestionWithStats
2655
13x
    for rows.Next() {
2656
11x
        question, err := s.scanQuestionWithStatsAndReportersFromRows(rows)
2657
11x
        if err != nil {
2658
            return nil, 0, err
2659
        }
2660
11x
        questions = append(questions, question)
2661
    }
2662

2663
13x
    return questions, total, nil
2664
}
2665

2666
// GetReportedQuestionsStats returns statistics about reported questions
2667
1x
func (s *QuestionService) GetReportedQuestionsStats(ctx context.Context) (result0 map[string]interface{}, err error) {
2668
1x
    ctx, span := observability.TraceQuestionFunction(ctx, "get_reported_questions_stats")
2669
1x
    defer func() {
2670
1x
        if err != nil {
2671
            span.RecordError(err, trace.WithStackTrace(true))
2672
            span.SetStatus(codes.Error, err.Error())
2673
        }
2674
1x
        span.End()
2675
    }()
2676

2677
1x
    stats := make(map[string]interface{})
2678
1x

2679
1x
    // Get total reported questions
2680
1x
    var totalReported int
2681
1x
    err = s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM questions WHERE status = 'reported'`).Scan(&totalReported)
2682
1x
    if err != nil {
2683
        return nil, contextutils.WrapErrorf(contextutils.ErrDatabaseQuery, "failed to get total reported questions: %w", err)
2684
    }
2685
1x
    stats["total_reported"] = totalReported
2686
1x

2687
1x
    // Get reported questions by type
2688
1x
    rows, err := s.db.QueryContext(ctx, `
2689
1x
        SELECT type, COUNT(*) as count
2690
1x
        FROM questions
2691
1x
        WHERE status = 'reported'
2692
1x
        GROUP BY type
2693
1x
        ORDER BY count DESC
2694
1x
    `)
2695
1x
    if err != nil {
2696
        return nil, contextutils.WrapErrorf(contextutils.ErrDatabaseQuery, "failed to get reported questions by type: %w", err)
2697
    }
2698
1x
    defer func() {
2699
1x
        if closeErr := rows.Close(); closeErr != nil {
2700
            s.logger.Warn(ctx, "Warning: failed to close rows", map[string]interface{}{"error": closeErr.Error()})
2701
        }
2702
    }()
2703

2704
1x
    reportedByType := make(map[string]int)
2705
1x
    for rows.Next() {
2706
2x
        var questionType string
2707
2x
        var count int
2708
2x
        if err := rows.Scan(&questionType, &count); err != nil {
2709
            return nil, err
2710
        }
2711
2x
        reportedByType[questionType] = count
2712
    }
2713
1x
    stats["reported_by_type"] = reportedByType
2714
1x

2715
1x
    // Get reported questions by level
2716
1x
    rows, err = s.db.QueryContext(ctx, `
2717
1x
        SELECT level, COUNT(*) as count
2718
1x
        FROM questions
2719
1x
        WHERE status = 'reported'
2720
1x
        GROUP BY level
2721
1x
        ORDER BY count DESC
2722
1x
    `)
2723
1x
    if err != nil {
2724
        return nil, contextutils.WrapErrorf(contextutils.ErrDatabaseQuery, "failed to get reported questions by level: %w", err)
2725
    }
2726
1x
    defer func() {
2727
1x
        if closeErr := rows.Close(); closeErr != nil {
2728
            s.logger.Warn(ctx, "Warning: failed to close rows", map[string]interface{}{"error": closeErr.Error()})
2729
        }
2730
    }()
2731

2732
1x
    reportedByLevel := make(map[string]int)
2733
1x
    for rows.Next() {
2734
3x
        var level string
2735
3x
        var count int
2736
3x
        if err := rows.Scan(&level, &count); err != nil {
2737
            return nil, err
2738
        }
2739
3x
        reportedByLevel[level] = count
2740
    }
2741
1x
    stats["reported_by_level"] = reportedByLevel
2742
1x

2743
1x
    // Get reported questions by language
2744
1x
    rows, err = s.db.QueryContext(ctx, `
2745
1x
        SELECT language, COUNT(*) as count
2746
1x
        FROM questions
2747
1x
        WHERE status = 'reported'
2748
1x
        GROUP BY language
2749
1x
        ORDER BY count DESC
2750
1x
    `)
2751
1x
    if err != nil {
2752
        return nil, contextutils.WrapErrorf(contextutils.ErrDatabaseQuery, "failed to get reported questions by language: %w", err)
2753
    }
2754
1x
    defer func() {
2755
1x
        if closeErr := rows.Close(); closeErr != nil {
2756
            s.logger.Warn(ctx, "Warning: failed to close rows", map[string]interface{}{"error": closeErr.Error()})
2757
        }
2758
    }()
2759

2760
1x
    reportedByLanguage := make(map[string]int)
2761
1x
    for rows.Next() {
2762
2x
        var language string
2763
2x
        var count int
2764
2x
        if err := rows.Scan(&language, &count); err != nil {
2765
            return nil, err
2766
        }
2767
2x
        reportedByLanguage[language] = count
2768
    }
2769
1x
    stats["reported_by_language"] = reportedByLanguage
2770
1x

2771
1x
    return stats, nil
2772
}
2773

2774
// AssignUsersToQuestion assigns multiple users to a question
2775
func (s *QuestionService) AssignUsersToQuestion(ctx context.Context, questionID int, userIDs []int) (err error) {
2776
    ctx, span := observability.TraceQuestionFunction(ctx, "assign_users_to_question", observability.AttributeQuestionID(questionID))
2777
    defer func() {
2778
        if err != nil {
2779
            span.RecordError(err, trace.WithStackTrace(true))
2780
            span.SetStatus(codes.Error, err.Error())
2781
        }
2782
        span.End()
2783
    }()
2784

2785
    // Start a transaction
2786
    tx, err := s.db.BeginTx(ctx, nil)
2787
    if err != nil {
2788
        return contextutils.WrapError(err, "failed to begin transaction")
2789
    }
2790
    defer func() {
2791
        if err != nil {
2792
            if rollbackErr := tx.Rollback(); rollbackErr != nil {
2793
                s.logger.Warn(ctx, "Failed to rollback transaction", map[string]interface{}{"error": rollbackErr.Error()})
2794
            }
2795
        }
2796
    }()
2797

2798
    // Prepare the insert statement
2799
    stmt, err := tx.PrepareContext(ctx, `
2800
        INSERT INTO user_questions (user_id, question_id, created_at)
2801
        VALUES ($1, $2, NOW())
2802
        ON CONFLICT (user_id, question_id) DO NOTHING
2803
    `)
2804
    if err != nil {
2805
        return contextutils.WrapError(err, "failed to prepare insert statement")
2806
    }
2807
    defer func() {
2808
        if closeErr := stmt.Close(); closeErr != nil {
2809
            s.logger.Warn(ctx, "Warning: failed to close statement", map[string]interface{}{"error": closeErr.Error()})
2810
        }
2811
    }()
2812

2813
    // Insert each user-question mapping
2814
    for _, userID := range userIDs {
2815
        _, err = stmt.ExecContext(ctx, userID, questionID)
2816
        if err != nil {
2817
            return contextutils.WrapErrorf(err, "failed to assign user %d to question %d", userID, questionID)
2818
        }
2819
    }
2820

2821
    // Commit the transaction
2822
    err = tx.Commit()
2823
    if err != nil {
2824
        return contextutils.WrapError(err, "failed to commit transaction")
2825
    }
2826

2827
    return nil
2828
}
2829

2830
// UnassignUsersFromQuestion removes multiple users from a question
2831
func (s *QuestionService) UnassignUsersFromQuestion(ctx context.Context, questionID int, userIDs []int) (err error) {
2832
    ctx, span := observability.TraceQuestionFunction(ctx, "unassign_users_from_question", observability.AttributeQuestionID(questionID))
2833
    defer func() {
2834
        if err != nil {
2835
            span.RecordError(err, trace.WithStackTrace(true))
2836
            span.SetStatus(codes.Error, err.Error())
2837
        }
2838
        span.End()
2839
    }()
2840

2841
    // Start a transaction
2842
    tx, err := s.db.BeginTx(ctx, nil)
2843
    if err != nil {
2844
        return contextutils.WrapError(err, "failed to begin transaction")
2845
    }
2846
    defer func() {
2847
        if err != nil {
2848
            if rollbackErr := tx.Rollback(); rollbackErr != nil {
2849
                s.logger.Warn(ctx, "Failed to rollback transaction", map[string]interface{}{"error": rollbackErr.Error()})
2850
            }
2851
        }
2852
    }()
2853

2854
    // Prepare the delete statement
2855
    stmt, err := tx.PrepareContext(ctx, `
2856
        DELETE FROM user_questions
2857
        WHERE user_id = $1 AND question_id = $2
2858
    `)
2859
    if err != nil {
2860
        return contextutils.WrapError(err, "failed to prepare delete statement")
2861
    }
2862
    defer func() {
2863
        if closeErr := stmt.Close(); closeErr != nil {
2864
            s.logger.Warn(ctx, "Warning: failed to close statement", map[string]interface{}{"error": closeErr.Error()})
2865
        }
2866
    }()
2867

2868
    // Delete each user-question mapping
2869
    for _, userID := range userIDs {
2870
        _, err = stmt.ExecContext(ctx, userID, questionID)
2871
        if err != nil {
2872
            return contextutils.WrapErrorf(err, "failed to unassign user %d from question %d", userID, questionID)
2873
        }
2874
    }
2875

2876
    // Commit the transaction
2877
    err = tx.Commit()
2878
    if err != nil {
2879
        return contextutils.WrapError(err, "failed to commit transaction")
2880
    }
2881

2882
    return nil
2883
}
2884

2885
// DB returns the underlying *sql.DB instance
2886
func (s *QuestionService) DB() *sql.DB {
2887
    return s.db
2888
}
2889


			
quizapp internal services worker_service.go
64.3%
Statements
232/361
1
package services
2

3
import (
4
    "context"
5
    "database/sql"
6
    "fmt"
7
    "strings"
8

9
    "quizapp/internal/api"
10
    "quizapp/internal/config"
11
    "quizapp/internal/models"
12
    "quizapp/internal/observability"
13
    "quizapp/internal/serviceinterfaces"
14
    contextutils "quizapp/internal/utils"
15

16
    "github.com/lib/pq"
17
    "go.opentelemetry.io/otel/attribute"
18
)
19

20
// SnippetsServiceInterface defines the interface for snippets services
21
type SnippetsServiceInterface = serviceinterfaces.SnippetsService
22

23
// SnippetsService handles snippets related business logic
24
type SnippetsService struct {
25
    db     *sql.DB
26
    cfg    *config.Config
27
    logger *observability.Logger
28
}
29

30
// NewSnippetsService creates a new SnippetsService instance
31
10x
func NewSnippetsService(db *sql.DB, cfg *config.Config, logger *observability.Logger) *SnippetsService {
32
10x
    return &SnippetsService{
33
10x
        db:     db,
34
10x
        cfg:    cfg,
35
10x
        logger: logger,
36
10x
    }
37
10x
}
38

39
// getDefaultDifficultyLevel returns a sensible default difficulty level when no question context is available
40
14x
func (s *SnippetsService) getDefaultDifficultyLevel() string {
41
14x
    // Default to "Unknown" when no question context is available
42
14x
    // Users can always update this through the UI if needed
43
14x
    return "Unknown"
44
14x
}
45

46
// getQuestionLevel retrieves the difficulty level of a specific question
47
2x
func (s *SnippetsService) getQuestionLevel(ctx context.Context, questionID int64) (result string, err error) {
48
2x
    ctx, span := observability.TraceQuestionFunction(ctx, "get_question_level",
49
2x
        observability.AttributeQuestionID(int(questionID)),
50
2x
    )
51
2x
    defer observability.FinishSpan(span, &err)
52
2x

53
2x
    // Check if database connection is valid
54
2x
    if s.db == nil {
55
        return "", contextutils.WrapError(contextutils.ErrInternalError, "database connection is nil")
56
    }
57

58
2x
    query := `SELECT level FROM questions WHERE id = $1`
59
2x

60
2x
    err = s.db.QueryRowContext(ctx, query, questionID).Scan(&result)
61
2x
    if err != nil {
62
        if err == sql.ErrNoRows {
63
            return "", contextutils.WrapErrorf(contextutils.ErrRecordNotFound, "question with id %d not found", questionID)
64
        }
65
        return "", contextutils.WrapErrorf(err, "failed to get question level for question %d", questionID)
66
    }
67
2x
    return result, nil
68
}
69

70
// CreateSnippet creates a new vocabulary snippet
71
17x
func (s *SnippetsService) CreateSnippet(ctx context.Context, userID int64, req api.CreateSnippetRequest) (result *models.Snippet, err error) {
72
17x
    ctx, span := observability.TraceFunction(ctx, "snippets", "create_snippet")
73
17x
    defer observability.FinishSpan(span, &err)
74
17x

75
17x
    // Check if database connection is valid
76
17x
    if s.db == nil {
77
        return nil, contextutils.WrapError(contextutils.ErrInternalError, "database connection is nil")
78
    }
79

80
17x
    span.SetAttributes(observability.AttributeUserID(int(userID)))
81
17x

82
17x
    // Check if snippet already exists for this user and text combination
83
17x
    exists, err := s.snippetExists(ctx, userID, req.OriginalText, req.SourceLanguage, req.TargetLanguage)
84
17x
    if err != nil {
85
        return nil, contextutils.WrapErrorf(err, "failed to check snippet existence")
86
    }
87
17x
    if exists {
88
1x
        return nil, contextutils.WrapError(contextutils.ErrRecordExists, "snippet already exists for this user and text combination")
89
1x
    }
90

91
    // Determine difficulty level - use question's level if question_id is provided, or section's level if section_id is provided
92
16x
    var difficultyLevel string
93
16x
    var levelSource string
94
16x

95
16x
    if req.QuestionId != nil {
96
2x
        // Get the question's difficulty level
97
2x
        questionLevel, err := s.getQuestionLevel(ctx, *req.QuestionId)
98
2x
        if err != nil {
99
            // If we can't get the question level, use default
100
            s.logger.Warn(ctx, "Failed to get question level, using default",
101
                map[string]any{"question_id": *req.QuestionId, "error": err.Error()})
102
            difficultyLevel = s.getDefaultDifficultyLevel()
103
            levelSource = "default_fallback"
104
        } else {
105
2x
            difficultyLevel = questionLevel
106
2x
            levelSource = "question"
107
2x
        }
108
14x
    } else if req.SectionId != nil {
109
        // Get the story section's language level
110
        sectionLevel, err := s.getSectionLevel(ctx, *req.SectionId)
111
        if err != nil {
112
            // If we can't get the section level, use default
113
            s.logger.Warn(ctx, "Failed to get section level, using default",
114
                map[string]any{"section_id": *req.SectionId, "error": err.Error()})
115
            difficultyLevel = s.getDefaultDifficultyLevel()
116
            levelSource = "default_fallback"
117
        } else {
118
            difficultyLevel = sectionLevel
119
            levelSource = "section"
120
        }
121
14x
    } else {
122
14x
        // No question or section context, use default
123
14x
        difficultyLevel = s.getDefaultDifficultyLevel()
124
14x
        levelSource = "default"
125
14x
    }
126
16x
    span.SetAttributes(observability.AttributeLevel(difficultyLevel))
127
16x

128
16x
    // Insert new snippet
129
16x
    query := `
130
16x
        INSERT INTO snippets (user_id, original_text, translated_text, source_language, target_language, question_id, section_id, story_id, context, difficulty_level)
131
16x
        VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
132
16x
        RETURNING id, created_at, updated_at`
133
16x

134
16x
    result = &models.Snippet{}
135
16x
    err = s.db.QueryRowContext(ctx, query,
136
16x
        userID,
137
16x
        req.OriginalText,
138
16x
        req.TranslatedText,
139
16x
        req.SourceLanguage,
140
16x
        req.TargetLanguage,
141
16x
        req.QuestionId,
142
16x
        req.SectionId,
143
16x
        req.StoryId,
144
16x
        req.Context,
145
16x
        difficultyLevel,
146
16x
    ).Scan(&result.ID, &result.CreatedAt, &result.UpdatedAt)
147
16x
    if err != nil {
148
        return nil, contextutils.WrapErrorf(err, "failed to create snippet")
149
    }
150

151
    // Set the remaining fields
152
16x
    result.UserID = userID
153
16x
    result.OriginalText = req.OriginalText
154
16x
    result.TranslatedText = req.TranslatedText
155
16x
    result.SourceLanguage = req.SourceLanguage
156
16x
    result.TargetLanguage = req.TargetLanguage
157
16x
    result.QuestionID = req.QuestionId
158
16x
    result.SectionID = req.SectionId
159
16x
    result.StoryID = req.StoryId
160
16x
    result.Context = req.Context
161
16x
    result.DifficultyLevel = &difficultyLevel
162
16x

163
16x
    s.logger.Info(ctx, "Created new snippet",
164
16x
        map[string]any{
165
16x
            "snippet_id":       result.ID,
166
16x
            "user_id":          userID,
167
16x
            "original_text":    req.OriginalText,
168
16x
            "source_language":  req.SourceLanguage,
169
16x
            "difficulty_level": difficultyLevel,
170
16x
            "level_source":     levelSource,
171
16x
            "question_id":      req.QuestionId,
172
16x
        })
173
16x

174
16x
    return result, nil
175
}
176

177
// getSectionLevel retrieves the language level of a specific story section
178
func (s *SnippetsService) getSectionLevel(ctx context.Context, sectionID int64) (result string, err error) {
179
    ctx, span := observability.TraceFunction(ctx, "snippets", "get_section_level")
180
    defer observability.FinishSpan(span, &err)
181

182
    // Check if database connection is valid
183
    if s.db == nil {
184
        return "", contextutils.WrapError(contextutils.ErrInternalError, "database connection is nil")
185
    }
186

187
    query := `SELECT language_level FROM story_sections WHERE id = $1`
188

189
    err = s.db.QueryRowContext(ctx, query, sectionID).Scan(&result)
190
    if err != nil {
191
        if err == sql.ErrNoRows {
192
            return "", contextutils.WrapErrorf(contextutils.ErrRecordNotFound, "story section with id %d not found", sectionID)
193
        }
194
        return "", contextutils.WrapErrorf(err, "failed to get section level for section %d", sectionID)
195
    }
196
    return result, nil
197
}
198

199
// GetSnippets retrieves snippets for a user with optional filtering
200
10x
func (s *SnippetsService) GetSnippets(ctx context.Context, userID int64, params api.GetV1SnippetsParams) (result *api.SnippetList, err error) {
201
10x
    ctx, span := observability.TraceFunction(ctx, "snippets", "get_snippets")
202
10x
    defer observability.FinishSpan(span, &err)
203
10x

204
10x
    // Check if database connection is valid
205
10x
    if s.db == nil {
206
        return nil, contextutils.WrapError(contextutils.ErrInternalError, "database connection is nil")
207
    }
208

209
10x
    span.SetAttributes(observability.AttributeUserID(int(userID)))
210
10x

211
10x
    query := `
212
10x
        SELECT id, user_id, original_text, translated_text, source_language, target_language,
213
10x
               question_id, section_id, story_id, context, difficulty_level, created_at, updated_at
214
10x
        FROM snippets
215
10x
        WHERE user_id = $1`
216
10x

217
10x
    args := []any{userID}
218
10x
    argCount := 1
219
10x

220
10x
    // Add search filter if provided
221
10x
    if params.Q != nil && *params.Q != "" {
222
2x
        argCount++
223
2x
        query += fmt.Sprintf(" AND (original_text ILIKE $%d OR translated_text ILIKE $%d)", argCount, argCount)
224
2x
        searchTerm := "%" + *params.Q + "%"
225
2x
        args = append(args, searchTerm)
226
2x
    }
227

228
    // Add source language filter if provided
229
10x
    if params.SourceLang != nil && *params.SourceLang != "" {
230
1x
        argCount++
231
1x
        query += fmt.Sprintf(" AND source_language = $%d", argCount)
232
1x
        args = append(args, *params.SourceLang)
233
1x
    }
234

235
    // Add target language filter if provided
236
10x
    if params.TargetLang != nil && *params.TargetLang != "" {
237
        argCount++
238
        query += fmt.Sprintf(" AND target_language = $%d", argCount)
239
        args = append(args, *params.TargetLang)
240
    }
241

242
    // Add story_id filter if provided
243
10x
    if params.StoryId != nil && *params.StoryId > 0 {
244
1x
        argCount++
245
1x
        query += fmt.Sprintf(" AND story_id = $%d", argCount)
246
1x
        args = append(args, *params.StoryId)
247
1x
    }
248

249
    // Add difficulty level filter if provided
250
10x
    if params.Level != nil && *params.Level != "" {
251
4x
        argCount++
252
4x
        query += fmt.Sprintf(" AND difficulty_level = $%d", argCount)
253
4x
        args = append(args, string(*params.Level))
254
4x
    }
255

256
    // Add ordering and pagination
257
10x
    query += " ORDER BY created_at DESC"
258
10x

259
10x
    if params.Limit != nil && *params.Limit > 0 {
260
        argCount++
261
        query += fmt.Sprintf(" LIMIT $%d", argCount)
262
        limit := *params.Limit
263
        if limit > 100 { // Max limit
264
            limit = 100
265
        }
266
        args = append(args, limit)
267
    }
268

269
10x
    if params.Offset != nil && *params.Offset > 0 {
270
        argCount++
271
        query += fmt.Sprintf(" OFFSET $%d", argCount)
272
        args = append(args, *params.Offset)
273
    }
274

275
    // Execute query
276
10x
    rows, err := s.db.QueryContext(ctx, query, args...)
277
10x
    if err != nil {
278
        return nil, contextutils.WrapErrorf(err, "failed to query snippets")
279
    }
280
10x
    defer func() {
281
10x
        if closeErr := rows.Close(); closeErr != nil {
282
            s.logger.Warn(ctx, "Failed to close rows", map[string]any{"error": closeErr.Error()})
283
        }
284
    }()
285

286
10x
    snippets := []api.Snippet{}
287
10x
    for rows.Next() {
288
11x
        var snippet models.Snippet
289
11x
        err := rows.Scan(
290
11x
            &snippet.ID,
291
11x
            &snippet.UserID,
292
11x
            &snippet.OriginalText,
293
11x
            &snippet.TranslatedText,
294
11x
            &snippet.SourceLanguage,
295
11x
            &snippet.TargetLanguage,
296
11x
            &snippet.QuestionID,
297
11x
            &snippet.SectionID,
298
11x
            &snippet.StoryID,
299
11x
            &snippet.Context,
300
11x
            &snippet.DifficultyLevel,
301
11x
            &snippet.CreatedAt,
302
11x
            &snippet.UpdatedAt,
303
11x
        )
304
11x
        if err != nil {
305
            return nil, contextutils.WrapErrorf(err, "failed to scan snippet")
306
        }
307

308
11x
        snippets = append(snippets, api.Snippet{
309
11x
            Id:              &snippet.ID,
310
11x
            UserId:          &snippet.UserID,
311
11x
            OriginalText:    &snippet.OriginalText,
312
11x
            TranslatedText:  &snippet.TranslatedText,
313
11x
            SourceLanguage:  &snippet.SourceLanguage,
314
11x
            TargetLanguage:  &snippet.TargetLanguage,
315
11x
            QuestionId:      snippet.QuestionID,
316
11x
            SectionId:       snippet.SectionID,
317
11x
            StoryId:         snippet.StoryID,
318
11x
            Context:         snippet.Context,
319
11x
            DifficultyLevel: snippet.DifficultyLevel,
320
11x
            CreatedAt:       &snippet.CreatedAt,
321
11x
            UpdatedAt:       &snippet.UpdatedAt,
322
11x
        })
323
    }
324

325
    // Get total count for pagination info
326
10x
    totalQuery := "SELECT COUNT(*) FROM snippets WHERE user_id = $1"
327
10x
    totalArgs := []interface{}{userID}
328
10x

329
10x
    // Apply the same filters for total count
330
10x
    if params.Q != nil && *params.Q != "" {
331
2x
        totalQuery += " AND (original_text ILIKE $2 OR translated_text ILIKE $2)"
332
2x
        totalArgs = append(totalArgs, "%"+*params.Q+"%")
333
2x
    }
334
10x
    if params.SourceLang != nil && *params.SourceLang != "" {
335
1x
        totalQuery += fmt.Sprintf(" AND source_language = $%d", len(totalArgs)+1)
336
1x
        totalArgs = append(totalArgs, *params.SourceLang)
337
1x
    }
338
10x
    if params.TargetLang != nil && *params.TargetLang != "" {
339
        totalQuery += fmt.Sprintf(" AND target_language = $%d", len(totalArgs)+1)
340
        totalArgs = append(totalArgs, *params.TargetLang)
341
    }
342
10x
    if params.StoryId != nil && *params.StoryId > 0 {
343
1x
        totalQuery += fmt.Sprintf(" AND story_id = $%d", len(totalArgs)+1)
344
1x
        totalArgs = append(totalArgs, *params.StoryId)
345
1x
    }
346
10x
    if params.Level != nil && *params.Level != "" {
347
4x
        totalQuery += fmt.Sprintf(" AND difficulty_level = $%d", len(totalArgs)+1)
348
4x
        totalArgs = append(totalArgs, string(*params.Level))
349
4x
    }
350

351
10x
    var total int
352
10x
    err = s.db.QueryRowContext(ctx, totalQuery, totalArgs...).Scan(&total)
353
10x
    if err != nil {
354
        return nil, contextutils.WrapErrorf(err, "failed to get total count")
355
    }
356

357
    // Build response
358
10x
    limit := 50 // default
359
10x
    offset := 0 // default
360
10x
    if params.Limit != nil {
361
        limit = *params.Limit
362
    }
363
10x
    if params.Offset != nil {
364
        offset = *params.Offset
365
    }
366

367
10x
    result = &api.SnippetList{
368
10x
        Snippets: &snippets,
369
10x
        Total:    &total,
370
10x
        Limit:    &limit,
371
10x
        Offset:   &offset,
372
10x
        Query:    params.Q,
373
10x
    }
374
10x

375
10x
    return result, nil
376
}
377

378
// GetSnippetsByQuestion retrieves snippets for a user filtered by question ID
379
// This method is optimized for performance to support async loading in the UI
380
3x
func (s *SnippetsService) GetSnippetsByQuestion(ctx context.Context, userID, questionID int64) (result []api.Snippet, err error) {
381
3x
    ctx, span := observability.TraceFunction(ctx, "snippets", "get_snippets_by_question")
382
3x
    defer observability.FinishSpan(span, &err)
383
3x

384
3x
    // Check if database connection is valid
385
3x
    if s.db == nil {
386
        return nil, contextutils.WrapError(contextutils.ErrInternalError, "database connection is nil")
387
    }
388

389
3x
    span.SetAttributes(
390
3x
        observability.AttributeUserID(int(userID)),
391
3x
        observability.AttributeQuestionID(int(questionID)),
392
3x
    )
393
3x

394
3x
    // Query snippets for this user and question
395
3x
    // Uses the existing idx_snippets_question_id index for performance
396
3x
    query := `
397
3x
        SELECT id, user_id, original_text, translated_text, source_language, target_language,
398
3x
               question_id, context, difficulty_level, created_at, updated_at
399
3x
        FROM snippets
400
3x
        WHERE user_id = $1 AND question_id = $2
401
3x
        ORDER BY created_at DESC`
402
3x

403
3x
    rows, err := s.db.QueryContext(ctx, query, userID, questionID)
404
3x
    if err != nil {
405
        return nil, contextutils.WrapErrorf(err, "failed to get snippets by question")
406
    }
407
3x
    defer func() {
408
3x
        if closeErr := rows.Close(); closeErr != nil {
409
            s.logger.Warn(ctx, "Failed to close rows", map[string]any{"error": closeErr.Error()})
410
        }
411
    }()
412

413
3x
    snippets := []api.Snippet{}
414
3x
    for rows.Next() {
415
2x
        var snippet models.Snippet
416
2x
        err := rows.Scan(
417
2x
            &snippet.ID,
418
2x
            &snippet.UserID,
419
2x
            &snippet.OriginalText,
420
2x
            &snippet.TranslatedText,
421
2x
            &snippet.SourceLanguage,
422
2x
            &snippet.TargetLanguage,
423
2x
            &snippet.QuestionID,
424
2x
            &snippet.Context,
425
2x
            &snippet.DifficultyLevel,
426
2x
            &snippet.CreatedAt,
427
2x
            &snippet.UpdatedAt,
428
2x
        )
429
2x
        if err != nil {
430
            return nil, contextutils.WrapErrorf(err, "failed to scan snippet")
431
        }
432

433
2x
        snippets = append(snippets, api.Snippet{
434
2x
            Id:              &snippet.ID,
435
2x
            UserId:          &snippet.UserID,
436
2x
            OriginalText:    &snippet.OriginalText,
437
2x
            TranslatedText:  &snippet.TranslatedText,
438
2x
            SourceLanguage:  &snippet.SourceLanguage,
439
2x
            TargetLanguage:  &snippet.TargetLanguage,
440
2x
            QuestionId:      snippet.QuestionID,
441
2x
            Context:         snippet.Context,
442
2x
            DifficultyLevel: snippet.DifficultyLevel,
443
2x
            CreatedAt:       &snippet.CreatedAt,
444
2x
            UpdatedAt:       &snippet.UpdatedAt,
445
2x
        })
446
    }
447

448
3x
    if err = rows.Err(); err != nil {
449
        return nil, contextutils.WrapErrorf(err, "error iterating over snippet rows")
450
    }
451

452
3x
    return snippets, nil
453
}
454

455
// GetSnippetsBySection retrieves snippets for a user filtered by section ID
456
// This method is optimized for performance to support async loading in the UI
457
func (s *SnippetsService) GetSnippetsBySection(ctx context.Context, userID, sectionID int64) (result []api.Snippet, err error) {
458
    ctx, span := observability.TraceFunction(ctx, "snippets", "get_snippets_by_section")
459
    defer observability.FinishSpan(span, &err)
460

461
    // Check if database connection is valid
462
    if s.db == nil {
463
        return nil, contextutils.WrapError(contextutils.ErrInternalError, "database connection is nil")
464
    }
465

466
    span.SetAttributes(
467
        observability.AttributeUserID(int(userID)),
468
        attribute.Int64("section.id", sectionID),
469
    )
470

471
    // Query snippets for this user and section
472
    // Uses the new idx_snippets_section_id index for performance
473
    query := `
474
        SELECT id, user_id, original_text, translated_text, source_language, target_language,
475
               question_id, section_id, story_id, context, difficulty_level, created_at, updated_at
476
        FROM snippets
477
        WHERE user_id = $1 AND section_id = $2
478
        ORDER BY created_at DESC`
479

480
    rows, err := s.db.QueryContext(ctx, query, userID, sectionID)
481
    if err != nil {
482
        return nil, contextutils.WrapErrorf(err, "failed to get snippets by section")
483
    }
484
    defer func() {
485
        if closeErr := rows.Close(); closeErr != nil {
486
            s.logger.Warn(ctx, "Failed to close rows", map[string]any{"error": closeErr.Error()})
487
        }
488
    }()
489

490
    snippets := []api.Snippet{}
491
    for rows.Next() {
492
        var snippet models.Snippet
493
        err := rows.Scan(
494
            &snippet.ID,
495
            &snippet.UserID,
496
            &snippet.OriginalText,
497
            &snippet.TranslatedText,
498
            &snippet.SourceLanguage,
499
            &snippet.TargetLanguage,
500
            &snippet.QuestionID,
501
            &snippet.SectionID,
502
            &snippet.StoryID,
503
            &snippet.Context,
504
            &snippet.DifficultyLevel,
505
            &snippet.CreatedAt,
506
            &snippet.UpdatedAt,
507
        )
508
        if err != nil {
509
            return nil, contextutils.WrapErrorf(err, "failed to scan snippet")
510
        }
511

512
        snippets = append(snippets, api.Snippet{
513
            Id:              &snippet.ID,
514
            UserId:          &snippet.UserID,
515
            OriginalText:    &snippet.OriginalText,
516
            TranslatedText:  &snippet.TranslatedText,
517
            SourceLanguage:  &snippet.SourceLanguage,
518
            TargetLanguage:  &snippet.TargetLanguage,
519
            QuestionId:      snippet.QuestionID,
520
            SectionId:       snippet.SectionID,
521
            StoryId:         snippet.StoryID,
522
            Context:         snippet.Context,
523
            DifficultyLevel: snippet.DifficultyLevel,
524
            CreatedAt:       &snippet.CreatedAt,
525
            UpdatedAt:       &snippet.UpdatedAt,
526
        })
527
    }
528

529
    if err = rows.Err(); err != nil {
530
        return nil, contextutils.WrapErrorf(err, "error iterating over snippet rows")
531
    }
532

533
    return snippets, nil
534
}
535

536
// GetSnippetsByStory retrieves snippets for a user filtered by story ID
537
// This method is optimized for performance to support async loading in the UI
538
func (s *SnippetsService) GetSnippetsByStory(ctx context.Context, userID, storyID int64) (result []api.Snippet, err error) {
539
    ctx, span := observability.TraceFunction(ctx, "snippets", "get_snippets_by_story")
540
    defer observability.FinishSpan(span, &err)
541

542
    // Check if database connection is valid
543
    if s.db == nil {
544
        return nil, contextutils.WrapError(contextutils.ErrInternalError, "database connection is nil")
545
    }
546

547
    span.SetAttributes(
548
        observability.AttributeUserID(int(userID)),
549
        attribute.Int64("story.id", storyID),
550
    )
551

552
    // Query snippets for this user and story
553
    // Uses the new idx_snippets_story_id index for performance
554
    query := `
555
        SELECT id, user_id, original_text, translated_text, source_language, target_language,
556
               question_id, section_id, story_id, context, difficulty_level, created_at, updated_at
557
        FROM snippets
558
        WHERE user_id = $1 AND story_id = $2
559
        ORDER BY created_at DESC`
560

561
    rows, err := s.db.QueryContext(ctx, query, userID, storyID)
562
    if err != nil {
563
        return nil, contextutils.WrapErrorf(err, "failed to get snippets by story")
564
    }
565
    defer func() {
566
        if closeErr := rows.Close(); closeErr != nil {
567
            s.logger.Warn(ctx, "Failed to close rows", map[string]any{"error": closeErr.Error()})
568
        }
569
    }()
570

571
    snippets := []api.Snippet{}
572
    for rows.Next() {
573
        var snippet models.Snippet
574
        err := rows.Scan(
575
            &snippet.ID,
576
            &snippet.UserID,
577
            &snippet.OriginalText,
578
            &snippet.TranslatedText,
579
            &snippet.SourceLanguage,
580
            &snippet.TargetLanguage,
581
            &snippet.QuestionID,
582
            &snippet.SectionID,
583
            &snippet.StoryID,
584
            &snippet.Context,
585
            &snippet.DifficultyLevel,
586
            &snippet.CreatedAt,
587
            &snippet.UpdatedAt,
588
        )
589
        if err != nil {
590
            return nil, contextutils.WrapErrorf(err, "failed to scan snippet")
591
        }
592

593
        snippets = append(snippets, api.Snippet{
594
            Id:              &snippet.ID,
595
            UserId:          &snippet.UserID,
596
            OriginalText:    &snippet.OriginalText,
597
            TranslatedText:  &snippet.TranslatedText,
598
            SourceLanguage:  &snippet.SourceLanguage,
599
            TargetLanguage:  &snippet.TargetLanguage,
600
            QuestionId:      snippet.QuestionID,
601
            SectionId:       snippet.SectionID,
602
            StoryId:         snippet.StoryID,
603
            Context:         snippet.Context,
604
            DifficultyLevel: snippet.DifficultyLevel,
605
            CreatedAt:       &snippet.CreatedAt,
606
            UpdatedAt:       &snippet.UpdatedAt,
607
        })
608
    }
609

610
    if err = rows.Err(); err != nil {
611
        return nil, contextutils.WrapErrorf(err, "error iterating over snippet rows")
612
    }
613

614
    return snippets, nil
615
}
616

617
// SearchSnippets searches across all snippets for a user
618
3x
func (s *SnippetsService) SearchSnippets(ctx context.Context, userID int64, query string, limit, offset int, sourceLang *string) (result []api.Snippet, totalCount int, err error) {
619
3x
    ctx, span := observability.TraceFunction(ctx, "snippets", "search_snippets")
620
3x
    defer observability.FinishSpan(span, &err)
621
3x

622
3x
    // Check if database connection is valid
623
3x
    if s.db == nil {
624
        return nil, 0, contextutils.WrapError(contextutils.ErrInternalError, "database connection is nil")
625
    }
626

627
3x
    span.SetAttributes(observability.AttributeUserID(int(userID)))
628
3x

629
3x
    // Clean and prepare the search query
630
3x
    searchQuery := strings.TrimSpace(query)
631
3x
    if searchQuery == "" {
632
        return nil, 0, contextutils.WrapError(contextutils.ErrInvalidInput, "search query cannot be empty")
633
    }
634

635
    // Search in both original_text and translated_text
636
3x
    searchTerm := fmt.Sprintf("%%%s%%", strings.ToLower(searchQuery))
637
3x

638
3x
    // Get total count of matching snippets
639
3x
    totalQuery := `
640
3x
        SELECT COUNT(*)
641
3x
        FROM snippets
642
3x
        WHERE user_id = $1 AND (LOWER(original_text) LIKE $2 OR LOWER(translated_text) LIKE $3)`
643
3x

644
3x
    var total int
645
3x
    // Add optional source language filter
646
3x
    totalArgs := []any{userID, searchTerm, searchTerm}
647
3x
    if sourceLang != nil && *sourceLang != "" {
648
2x
        totalQuery += " AND source_language = $4"
649
2x
        totalArgs = append(totalArgs, *sourceLang)
650
2x
    }
651

652
3x
    err = s.db.QueryRowContext(ctx, totalQuery, totalArgs...).Scan(&total)
653
3x
    if err != nil {
654
        return nil, 0, contextutils.WrapErrorf(err, "failed to get total count for search")
655
    }
656

657
    // Get matching snippets
658
3x
    queryStr := `
659
3x
        SELECT id, user_id, original_text, translated_text, source_language, target_language,
660
3x
               question_id, section_id, story_id, context, difficulty_level, created_at, updated_at
661
3x
        FROM snippets
662
3x
        WHERE user_id = $1 AND (LOWER(original_text) LIKE $2 OR LOWER(translated_text) LIKE $3)`
663
3x
    args := []any{userID, searchTerm, searchTerm}
664
3x
    if sourceLang != nil && *sourceLang != "" {
665
2x
        queryStr += " AND source_language = $4"
666
2x
        args = append(args, *sourceLang)
667
2x
        queryStr += " ORDER BY created_at DESC LIMIT $5 OFFSET $6"
668
2x
        args = append(args, limit, offset)
669
2x
    } else {
670
1x
        queryStr += " ORDER BY created_at DESC LIMIT $4 OFFSET $5"
671
1x
        args = append(args, limit, offset)
672
1x
    }
673

674
3x
    rows, err := s.db.QueryContext(ctx, queryStr, args...)
675
3x
    if err != nil {
676
        return nil, 0, contextutils.WrapErrorf(err, "failed to search snippets")
677
    }
678
3x
    defer func() {
679
3x
        if closeErr := rows.Close(); closeErr != nil {
680
            s.logger.Warn(ctx, "Failed to close rows", map[string]any{"error": closeErr.Error()})
681
        }
682
    }()
683

684
3x
    snippets := []api.Snippet{}
685
3x
    for rows.Next() {
686
2x
        var snippet models.Snippet
687
2x
        err := rows.Scan(
688
2x
            &snippet.ID,
689
2x
            &snippet.UserID,
690
2x
            &snippet.OriginalText,
691
2x
            &snippet.TranslatedText,
692
2x
            &snippet.SourceLanguage,
693
2x
            &snippet.TargetLanguage,
694
2x
            &snippet.QuestionID,
695
2x
            &snippet.SectionID,
696
2x
            &snippet.StoryID,
697
2x
            &snippet.Context,
698
2x
            &snippet.DifficultyLevel,
699
2x
            &snippet.CreatedAt,
700
2x
            &snippet.UpdatedAt,
701
2x
        )
702
2x
        if err != nil {
703
            return nil, 0, contextutils.WrapErrorf(err, "failed to scan snippet")
704
        }
705

706
2x
        snippets = append(snippets, api.Snippet{
707
2x
            Id:              &snippet.ID,
708
2x
            UserId:          &snippet.UserID,
709
2x
            OriginalText:    &snippet.OriginalText,
710
2x
            TranslatedText:  &snippet.TranslatedText,
711
2x
            SourceLanguage:  &snippet.SourceLanguage,
712
2x
            TargetLanguage:  &snippet.TargetLanguage,
713
2x
            QuestionId:      snippet.QuestionID,
714
2x
            SectionId:       snippet.SectionID,
715
2x
            StoryId:         snippet.StoryID,
716
2x
            Context:         snippet.Context,
717
2x
            DifficultyLevel: snippet.DifficultyLevel,
718
2x
            CreatedAt:       &snippet.CreatedAt,
719
2x
            UpdatedAt:       &snippet.UpdatedAt,
720
2x
        })
721
    }
722

723
3x
    return snippets, total, nil
724
}
725

726
// snippetExists checks if a snippet already exists for the user
727
17x
func (s *SnippetsService) snippetExists(ctx context.Context, userID int64, originalText, sourceLanguage, targetLanguage string) (bool, error) {
728
17x
    ctx, span := observability.TraceFunction(ctx, "snippets", "snippet_exists")
729
17x
    defer observability.FinishSpan(span, nil)
730
17x

731
17x
    // Check if database connection is valid
732
17x
    if s.db == nil {
733
        return false, contextutils.WrapError(contextutils.ErrInternalError, "database connection is nil")
734
    }
735

736
17x
    span.SetAttributes(observability.AttributeUserID(int(userID)))
737
17x

738
17x
    query := `
739
17x
        SELECT COUNT(*)
740
17x
        FROM snippets
741
17x
        WHERE user_id = $1 AND original_text = $2 AND source_language = $3 AND target_language = $4`
742
17x

743
17x
    var count int
744
17x
    err := s.db.QueryRowContext(ctx, query, userID, originalText, sourceLanguage, targetLanguage).Scan(&count)
745
17x
    if err != nil {
746
        return false, contextutils.WrapErrorf(err, "failed to check snippet existence")
747
    }
748

749
17x
    return count > 0, nil
750
}
751

752
// GetSnippet retrieves a specific snippet by ID
753
3x
func (s *SnippetsService) GetSnippet(ctx context.Context, userID, snippetID int64) (result *models.Snippet, err error) {
754
3x
    ctx, span := observability.TraceFunction(ctx, "snippets", "get_snippet")
755
3x
    defer observability.FinishSpan(span, &err)
756
3x

757
3x
    // Check if database connection is valid
758
3x
    if s.db == nil {
759
        return nil, contextutils.WrapError(contextutils.ErrInternalError, "database connection is nil")
760
    }
761

762
3x
    span.SetAttributes(observability.AttributeUserID(int(userID)))
763
3x
    span.SetAttributes(observability.AttributeSnippetID(int(snippetID)))
764
3x

765
3x
    query := `
766
3x
        SELECT id, user_id, original_text, translated_text, source_language, target_language,
767
3x
               question_id, context, difficulty_level, created_at, updated_at
768
3x
        FROM snippets
769
3x
        WHERE id = $1 AND user_id = $2`
770
3x

771
3x
    result = &models.Snippet{}
772
3x
    err = s.db.QueryRowContext(ctx, query, snippetID, userID).Scan(
773
3x
        &result.ID,
774
3x
        &result.UserID,
775
3x
        &result.OriginalText,
776
3x
        &result.TranslatedText,
777
3x
        &result.SourceLanguage,
778
3x
        &result.TargetLanguage,
779
3x
        &result.QuestionID,
780
3x
        &result.Context,
781
3x
        &result.DifficultyLevel,
782
3x
        &result.CreatedAt,
783
3x
        &result.UpdatedAt,
784
3x
    )
785
3x
    if err != nil {
786
2x
        if err == sql.ErrNoRows {
787
2x
            return nil, contextutils.WrapError(contextutils.ErrRecordNotFound, "snippet not found")
788
2x
        }
789
        return nil, contextutils.WrapErrorf(err, "failed to get snippet")
790
    }
791

792
1x
    return result, nil
793
}
794

795
// UpdateSnippet updates a snippet's fields
796
2x
func (s *SnippetsService) UpdateSnippet(ctx context.Context, userID, snippetID int64, req api.UpdateSnippetRequest) (result *models.Snippet, err error) {
797
2x
    ctx, span := observability.TraceFunction(ctx, "snippets", "update_snippet")
798
2x
    defer observability.FinishSpan(span, &err)
799
2x

800
2x
    // Check if database connection is valid
801
2x
    if s.db == nil {
802
        return nil, contextutils.WrapError(contextutils.ErrInternalError, "database connection is nil")
803
    }
804

805
2x
    span.SetAttributes(observability.AttributeUserID(int(userID)))
806
2x
    span.SetAttributes(observability.AttributeSnippetID(int(snippetID)))
807
2x

808
2x
    // Build dynamic query based on which fields are provided
809
2x
    setParts := []string{"updated_at = CURRENT_TIMESTAMP"}
810
2x
    args := []interface{}{}
811
2x
    argCount := 0
812
2x

813
2x
    if req.OriginalText != nil {
814
2x
        argCount++
815
2x
        setParts = append(setParts, fmt.Sprintf("original_text = $%d", argCount))
816
2x
        args = append(args, *req.OriginalText)
817
2x
    }
818

819
2x
    if req.TranslatedText != nil {
820
2x
        argCount++
821
2x
        setParts = append(setParts, fmt.Sprintf("translated_text = $%d", argCount))
822
2x
        args = append(args, *req.TranslatedText)
823
2x
    }
824

825
2x
    if req.SourceLanguage != nil {
826
2x
        argCount++
827
2x
        setParts = append(setParts, fmt.Sprintf("source_language = $%d", argCount))
828
2x
        args = append(args, *req.SourceLanguage)
829
2x
    }
830

831
2x
    if req.TargetLanguage != nil {
832
2x
        argCount++
833
2x
        setParts = append(setParts, fmt.Sprintf("target_language = $%d", argCount))
834
2x
        args = append(args, *req.TargetLanguage)
835
2x
    }
836

837
2x
    if req.Context != nil {
838
2x
        argCount++
839
2x
        setParts = append(setParts, fmt.Sprintf("context = $%d", argCount))
840
2x
        args = append(args, *req.Context)
841
2x
    }
842

843
2x
    if len(setParts) == 1 {
844
        // No fields to update
845
        return nil, contextutils.WrapError(contextutils.ErrInvalidInput, "no fields to update")
846
    }
847

848
    // Add WHERE clause parameters
849
2x
    argCount++
850
2x
    whereClause := fmt.Sprintf("WHERE id = $%d AND user_id = $%d", argCount, argCount+1)
851
2x
    args = append(args, snippetID, userID)
852
2x

853
2x
    query := fmt.Sprintf(`
854
2x
        UPDATE snippets
855
2x
        SET %s
856
2x
        %s
857
2x
        RETURNING id, user_id, original_text, translated_text, source_language, target_language,
858
2x
                  question_id, context, difficulty_level, created_at, updated_at`,
859
2x
        strings.Join(setParts, ", "), whereClause)
860
2x

861
2x
    result = &models.Snippet{}
862
2x
    err = s.db.QueryRowContext(ctx, query, args...).Scan(
863
2x
        &result.ID,
864
2x
        &result.UserID,
865
2x
        &result.OriginalText,
866
2x
        &result.TranslatedText,
867
2x
        &result.SourceLanguage,
868
2x
        &result.TargetLanguage,
869
2x
        &result.QuestionID,
870
2x
        &result.Context,
871
2x
        &result.DifficultyLevel,
872
2x
        &result.CreatedAt,
873
2x
        &result.UpdatedAt,
874
2x
    )
875
2x
    if err != nil {
876
1x
        if err == sql.ErrNoRows {
877
1x
            return nil, contextutils.WrapError(contextutils.ErrRecordNotFound, "snippet not found")
878
1x
        }
879
        // Map unique constraint violations to conflict error (409)
880
        if pqErr, ok := err.(*pq.Error); ok && pqErr.Code == "23505" {
881
            return nil, contextutils.WrapError(contextutils.ErrRecordExists, "a snippet with the same text and language already exists in this context")
882
        }
883
        return nil, contextutils.WrapErrorf(err, "failed to update snippet")
884
    }
885

886
1x
    s.logger.Info(ctx, "Updated snippet",
887
1x
        map[string]any{
888
1x
            "snippet_id": result.ID,
889
1x
            "user_id":    userID,
890
1x
        })
891
1x

892
1x
    return result, nil
893
}
894

895
// DeleteSnippet deletes a snippet
896
2x
func (s *SnippetsService) DeleteSnippet(ctx context.Context, userID, snippetID int64) (err error) {
897
2x
    ctx, span := observability.TraceFunction(ctx, "snippets", "delete_snippet")
898
2x
    defer observability.FinishSpan(span, &err)
899
2x

900
2x
    // Check if database connection is valid
901
2x
    if s.db == nil {
902
        return contextutils.WrapError(contextutils.ErrInternalError, "database connection is nil")
903
    }
904

905
2x
    span.SetAttributes(observability.AttributeUserID(int(userID)))
906
2x
    span.SetAttributes(observability.AttributeSnippetID(int(snippetID)))
907
2x

908
2x
    result, err := s.db.ExecContext(ctx, "DELETE FROM snippets WHERE id = $1 AND user_id = $2", snippetID, userID)
909
2x
    if err != nil {
910
        return contextutils.WrapErrorf(err, "failed to delete snippet")
911
    }
912

913
2x
    rowsAffected, err := result.RowsAffected()
914
2x
    if err != nil {
915
        return contextutils.WrapErrorf(err, "failed to get rows affected")
916
    }
917

918
2x
    if rowsAffected == 0 {
919
1x
        return contextutils.WrapError(contextutils.ErrRecordNotFound, "snippet not found")
920
1x
    }
921

922
1x
    s.logger.Info(ctx, "Deleted snippet",
923
1x
        map[string]any{
924
1x
            "snippet_id": snippetID,
925
1x
            "user_id":    userID,
926
1x
        })
927
1x

928
1x
    return nil
929
}
930

931
// DeleteAllSnippets deletes all snippets for a user
932
func (s *SnippetsService) DeleteAllSnippets(ctx context.Context, userID int64) (err error) {
933
    ctx, span := observability.TraceFunction(ctx, "snippets", "delete_all_snippets")
934
    defer observability.FinishSpan(span, &err)
935

936
    // Check if database connection is valid
937
    if s.db == nil {
938
        return contextutils.WrapError(contextutils.ErrInternalError, "database connection is nil")
939
    }
940

941
    span.SetAttributes(observability.AttributeUserID(int(userID)))
942

943
    result, err := s.db.ExecContext(ctx, "DELETE FROM snippets WHERE user_id = $1", userID)
944
    if err != nil {
945
        return contextutils.WrapErrorf(err, "failed to delete all snippets for user")
946
    }
947

948
    rowsAffected, err := result.RowsAffected()
949
    if err != nil {
950
        return contextutils.WrapErrorf(err, "failed to get rows affected")
951
    }
952

953
    s.logger.Info(ctx, "Deleted all snippets for user",
954
        map[string]any{
955
            "user_id":          userID,
956
            "snippets_deleted": rowsAffected,
957
        })
958

959
    return nil
960
}
961


			
quizapp internal services worker_service.go
38.5%
Statements
255/663
1
package services
2

3
import (
4
    "context"
5
    "database/sql"
6
    "encoding/json"
7
    "errors"
8
    "fmt"
9
    "strings"
10
    "time"
11

12
    "quizapp/internal/config"
13
    "quizapp/internal/models"
14
    "quizapp/internal/observability"
15
    contextutils "quizapp/internal/utils"
16

17
    "go.opentelemetry.io/otel/attribute"
18
)
19

20
// StoryServiceInterface defines the interface for story operations
21
type StoryServiceInterface interface {
22
    CreateStory(ctx context.Context, userID uint, language string, req *models.CreateStoryRequest) (*models.Story, error)
23
    GetUserStories(ctx context.Context, userID uint, includeArchived bool) ([]models.Story, error)
24
    GetCurrentStory(ctx context.Context, userID uint) (*models.StoryWithSections, error)
25
    GetStory(ctx context.Context, storyID, userID uint) (*models.StoryWithSections, error)
26
    ArchiveStory(ctx context.Context, storyID, userID uint) error
27
    CompleteStory(ctx context.Context, storyID, userID uint) error
28
    SetCurrentStory(ctx context.Context, storyID, userID uint) error
29
    ToggleAutoGeneration(ctx context.Context, storyID, userID uint, paused bool) error
30
    DeleteStory(ctx context.Context, storyID, userID uint) error
31
    DeleteAllStoriesForUser(ctx context.Context, userID uint) error
32
    FixCurrentStoryConstraint(ctx context.Context) error
33
    GetStorySections(ctx context.Context, storyID uint) ([]models.StorySection, error)
34
    GetSection(ctx context.Context, sectionID, userID uint) (*models.StorySectionWithQuestions, error)
35
    CreateSection(ctx context.Context, storyID uint, content, level string, wordCount int, generatedBy models.GeneratorType) (*models.StorySection, error)
36
    GetLatestSection(ctx context.Context, storyID uint) (*models.StorySection, error)
37
    GetAllSectionsText(ctx context.Context, storyID uint) (string, error)
38
    GetSectionQuestions(ctx context.Context, sectionID uint) ([]models.StorySectionQuestion, error)
39
    CreateSectionQuestions(ctx context.Context, sectionID uint, questions []models.StorySectionQuestionData) error
40
    GetRandomQuestions(ctx context.Context, sectionID uint, count int) ([]models.StorySectionQuestion, error)
41
    UpdateLastGenerationTime(ctx context.Context, storyID uint, generatorType models.GeneratorType) error
42
    RecordStorySectionView(ctx context.Context, userID, sectionID uint) error
43
    HasUserViewedLatestSection(ctx context.Context, userID uint) (bool, error)
44
    GetSectionLengthTarget(level string, lengthPref *models.SectionLength) int
45
    GetSectionLengthTargetWithLanguage(language, level string, lengthPref *models.SectionLength) int
46
    SanitizeInput(input string) string
47
    GenerateStorySection(ctx context.Context, storyID, userID uint, aiService AIServiceInterface, userAIConfig *models.UserAIConfig, generatorType models.GeneratorType) (*models.StorySectionWithQuestions, error)
48
    // Admin-only helpers (no ownership checks)
49
    GetStoriesPaginated(ctx context.Context, page, pageSize int, search, language, status string, userID *uint) ([]models.Story, int, error)
50
    GetStoryAdmin(ctx context.Context, storyID uint) (*models.StoryWithSections, error)
51
    GetSectionAdmin(ctx context.Context, sectionID uint) (*models.StorySectionWithQuestions, error)
52
    // Admin-only delete without ownership check
53
    DeleteStoryAdmin(ctx context.Context, storyID uint) error
54
}
55

56
// StoryService handles all story-related operations
57
type StoryService struct {
58
    db     *sql.DB
59
    config *config.Config
60
    logger *observability.Logger
61
}
62

63
// NewStoryService creates a new StoryService instance
64
17x
func NewStoryService(db *sql.DB, config *config.Config, logger *observability.Logger) *StoryService {
65
17x
    if db == nil {
66
1x
        panic("StoryService requires a valid database connection")
67
    }
68
15x
    return &StoryService{
69
15x
        db:     db,
70
15x
        config: config,
71
15x
        logger: logger,
72
15x
    }
73
}
74

75
// CreateStory creates a new story for the user
76
19x
func (s *StoryService) CreateStory(ctx context.Context, userID uint, language string, req *models.CreateStoryRequest) (*models.Story, error) {
77
19x
    if err := req.Validate(); err != nil {
78
        return nil, contextutils.WrapErrorf(err, "invalid story request")
79
    }
80

81
    // Check if user has reached the archived story limit
82
19x
    archivedCount, err := s.getArchivedStoryCount(ctx, userID)
83
19x
    if err != nil {
84
        return nil, contextutils.WrapErrorf(err, "failed to check archived story count")
85
    }
86

87
19x
    if archivedCount >= s.config.Story.MaxArchivedPerUser {
88
        return nil, contextutils.ErrorWithContextf("maximum archived stories limit reached (%d)", s.config.Story.MaxArchivedPerUser)
89
    }
90

91
    // Get user's current language level (stored for potential future use)
92
19x
    _, err = s.getUserCurrentLevel(ctx, userID)
93
19x
    if err != nil {
94
        return nil, contextutils.WrapErrorf(err, "failed to get user level")
95
    }
96

97
    // Unset any existing active story in the same language first
98
19x
    unsetQuery := "UPDATE stories SET status = $1, updated_at = NOW() WHERE user_id = $2 AND language = $3 AND status = $4"
99
19x
    _, err = s.db.ExecContext(ctx, unsetQuery, models.StoryStatusArchived, userID, language, models.StoryStatusActive)
100
19x
    if err != nil {
101
        return nil, contextutils.WrapErrorf(err, "failed to unset existing current story")
102
    }
103

104
    // Create the story
105
19x
    story := &models.Story{
106
19x
        UserID:                userID,
107
19x
        Title:                 req.Title,
108
19x
        Language:              language,
109
19x
        Subject:               req.Subject,
110
19x
        AuthorStyle:           req.AuthorStyle,
111
19x
        TimePeriod:            req.TimePeriod,
112
19x
        Genre:                 req.Genre,
113
19x
        Tone:                  req.Tone,
114
19x
        CharacterNames:        req.CharacterNames,
115
19x
        CustomInstructions:    req.CustomInstructions,
116
19x
        SectionLengthOverride: req.SectionLengthOverride,
117
19x
        Status:                models.StoryStatusActive,
118
19x
        CreatedAt:             time.Now(),
119
19x
        UpdatedAt:             time.Now(),
120
19x
    }
121
19x

122
19x
    if err := s.createStory(ctx, story); err != nil {
123
        return nil, contextutils.WrapErrorf(err, "failed to create story")
124
    }
125

126
19x
    s.logger.Info(context.Background(), "Story created successfully",
127
19x
        map[string]interface{}{
128
19x
            "story_id": story.ID,
129
19x
            "user_id":  userID,
130
19x
            "title":    story.Title,
131
19x
        })
132
19x

133
19x
    return story, nil
134
}
135

136
// GetUserStories retrieves all stories for a user in their preferred language
137
5x
func (s *StoryService) GetUserStories(ctx context.Context, userID uint, includeArchived bool) ([]models.Story, error) {
138
5x
    // Get user's preferred language
139
5x
    user, err := s.getUserByID(ctx, userID)
140
5x
    if err != nil {
141
        return nil, contextutils.WrapErrorf(err, "failed to get user")
142
    }
143

144
5x
    if user == nil {
145
1x
        // Return empty slice for non-existent user instead of error
146
1x
        return []models.Story{}, nil
147
1x
    }
148

149
4x
    language := "en" // default
150
4x
    if user.PreferredLanguage.Valid {
151
4x
        language = user.PreferredLanguage.String
152
4x
    }
153

154
4x
    query := `
155
4x
        SELECT id, user_id, title, language, subject, author_style, time_period, genre, tone,
156
4x
               character_names, custom_instructions, section_length_override, status,
157
4x
               auto_generation_paused, last_section_generated_at, created_at, updated_at
158
4x
        FROM stories
159
4x
        WHERE user_id = $1 AND language = $2`
160
4x

161
4x
    args := []interface{}{userID, language}
162
4x

163
4x
    if !includeArchived {
164
2x
        query += " AND status != $3"
165
2x
        args = append(args, models.StoryStatusArchived)
166
2x
    }
167

168
4x
    query += " ORDER BY status = 'active' DESC, created_at DESC"
169
4x

170
4x
    rows, err := s.db.QueryContext(ctx, query, args...)
171
4x
    if err != nil {
172
        return nil, err
173
    }
174
4x
    defer func() { _ = rows.Close() }()
175

176
4x
    stories := []models.Story{}
177
4x
    for rows.Next() {
178
5x
        var story models.Story
179
5x
        err := rows.Scan(
180
5x
            &story.ID, &story.UserID, &story.Title, &story.Language, &story.Subject,
181
5x
            &story.AuthorStyle, &story.TimePeriod, &story.Genre, &story.Tone,
182
5x
            &story.CharacterNames, &story.CustomInstructions, &story.SectionLengthOverride,
183
5x
            &story.Status, &story.AutoGenerationPaused,
184
5x
            &story.LastSectionGeneratedAt,
185
5x
            &story.CreatedAt, &story.UpdatedAt,
186
5x
        )
187
5x
        if err != nil {
188
            return nil, err
189
        }
190
5x
        stories = append(stories, story)
191
    }
192

193
4x
    return stories, rows.Err()
194
}
195

196
// GetCurrentStory retrieves the current active story for a user in their current language
197
17x
func (s *StoryService) GetCurrentStory(ctx context.Context, userID uint) (*models.StoryWithSections, error) {
198
17x
    // Get user's current language preference
199
17x
    user, err := s.getUserByID(ctx, userID)
200
17x
    if err != nil {
201
        return nil, contextutils.WrapErrorf(err, "failed to get user")
202
    }
203

204
17x
    if user == nil {
205
        return nil, contextutils.ErrorWithContextf("user not found: %d", userID)
206
    }
207

208
17x
    language := "en" // default
209
17x
    if user.PreferredLanguage.Valid {
210
17x
        language = user.PreferredLanguage.String
211
17x
    }
212

213
17x
    query := `
214
17x
        SELECT id, user_id, title, language, subject, author_style, time_period, genre, tone,
215
17x
               character_names, custom_instructions, section_length_override, status,
216
17x
               auto_generation_paused, last_section_generated_at, created_at, updated_at
217
17x
        FROM stories
218
17x
        WHERE user_id = $1 AND language = $2 AND status = $3 AND status != $4`
219
17x

220
17x
    var story models.Story
221
17x
    err = s.db.QueryRowContext(ctx, query, userID, language, models.StoryStatusActive, models.StoryStatusArchived).Scan(
222
17x
        &story.ID, &story.UserID, &story.Title, &story.Language, &story.Subject,
223
17x
        &story.AuthorStyle, &story.TimePeriod, &story.Genre, &story.Tone,
224
17x
        &story.CharacterNames, &story.CustomInstructions, &story.SectionLengthOverride,
225
17x
        &story.Status, &story.AutoGenerationPaused,
226
17x
        &story.LastSectionGeneratedAt,
227
17x
        &story.CreatedAt, &story.UpdatedAt,
228
17x
    )
229
17x
    if err != nil {
230
3x
        if errors.Is(err, sql.ErrNoRows) {
231
3x
            return nil, nil // No current story in user's language
232
3x
        }
233
        return nil, contextutils.WrapErrorf(err, "failed to get current story")
234
    }
235

236
    // Load sections
237
14x
    sections, err := s.GetStorySections(ctx, story.ID)
238
14x
    if err != nil {
239
        return nil, contextutils.WrapErrorf(err, "failed to load story sections")
240
    }
241

242
14x
    return &models.StoryWithSections{
243
14x
        Story:    story,
244
14x
        Sections: sections,
245
14x
    }, nil
246
}
247

248
// GetStory retrieves a specific story with ownership verification
249
func (s *StoryService) GetStory(ctx context.Context, storyID, userID uint) (*models.StoryWithSections, error) {
250
    query := `
251
        SELECT id, user_id, title, language, subject, author_style, time_period, genre, tone,
252
               character_names, custom_instructions, section_length_override, status,
253
               auto_generation_paused, last_section_generated_at, created_at, updated_at
254
        FROM stories
255
        WHERE id = $1 AND user_id = $2`
256

257
    var story models.Story
258
    err := s.db.QueryRowContext(ctx, query, storyID, userID).Scan(
259
        &story.ID, &story.UserID, &story.Title, &story.Language, &story.Subject,
260
        &story.AuthorStyle, &story.TimePeriod, &story.Genre, &story.Tone,
261
        &story.CharacterNames, &story.CustomInstructions, &story.SectionLengthOverride,
262
        &story.Status, &story.AutoGenerationPaused,
263
        &story.LastSectionGeneratedAt,
264
        &story.CreatedAt, &story.UpdatedAt,
265
    )
266
    if err != nil {
267
        if errors.Is(err, sql.ErrNoRows) {
268
            return nil, contextutils.ErrorWithContextf("story not found or access denied")
269
        }
270
        return nil, contextutils.WrapErrorf(err, "failed to get story")
271
    }
272

273
    // Load sections
274
    sections, err := s.GetStorySections(ctx, story.ID)
275
    if err != nil {
276
        return nil, contextutils.WrapErrorf(err, "failed to load story sections")
277
    }
278

279
    return &models.StoryWithSections{
280
        Story:    story,
281
        Sections: sections,
282
    }, nil
283
}
284

285
// ArchiveStory changes a story's status to archived
286
2x
func (s *StoryService) ArchiveStory(ctx context.Context, storyID, userID uint) error {
287
2x
    if err := s.validateStoryOwnership(ctx, storyID, userID); err != nil {
288
        return err
289
    }
290

291
    // First, check if the story is completed (completed stories cannot be archived)
292
2x
    var status string
293
2x
    checkQuery := "SELECT status FROM stories WHERE id = $1"
294
2x
    err := s.db.QueryRowContext(ctx, checkQuery, storyID).Scan(&status)
295
2x
    if err != nil {
296
        return contextutils.WrapErrorf(err, "failed to check story status")
297
    }
298

299
    // Prevent archiving completed stories
300
2x
    if status == string(models.StoryStatusCompleted) {
301
        return contextutils.ErrorWithContextf("cannot archive completed stories")
302
    }
303

304
    // Archive the story (this automatically removes it from being current since only active stories are current)
305
2x
    query := "UPDATE stories SET status = $1, updated_at = NOW() WHERE id = $2"
306
2x
    _, err = s.db.ExecContext(ctx, query, models.StoryStatusArchived, storyID)
307
2x
    if err != nil {
308
        return contextutils.WrapErrorf(err, "failed to archive story")
309
    }
310

311
2x
    s.logger.Info(context.Background(), "Story archived successfully",
312
2x
        map[string]interface{}{
313
2x
            "story_id": storyID,
314
2x
            "user_id":  userID,
315
2x
        })
316
2x

317
2x
    return nil
318
}
319

320
// CompleteStory changes a story's status to completed
321
func (s *StoryService) CompleteStory(ctx context.Context, storyID, userID uint) error {
322
    if err := s.validateStoryOwnership(ctx, storyID, userID); err != nil {
323
        return err
324
    }
325

326
    query := "UPDATE stories SET status = $1, updated_at = NOW() WHERE id = $2"
327
    _, err := s.db.ExecContext(ctx, query, models.StoryStatusCompleted, storyID)
328
    if err != nil {
329
        return contextutils.WrapErrorf(err, "failed to complete story")
330
    }
331

332
    s.logger.Info(context.Background(), "Story completed successfully",
333
        map[string]interface{}{
334
            "story_id": storyID,
335
            "user_id":  userID,
336
        })
337

338
    return nil
339
}
340

341
// ToggleAutoGeneration toggles the auto-generation pause state for a story
342
func (s *StoryService) ToggleAutoGeneration(ctx context.Context, storyID, userID uint, paused bool) error {
343
    if err := s.validateStoryOwnership(ctx, storyID, userID); err != nil {
344
        return err
345
    }
346

347
    query := "UPDATE stories SET auto_generation_paused = $1, updated_at = NOW() WHERE id = $2"
348
    _, err := s.db.ExecContext(ctx, query, paused, storyID)
349
    if err != nil {
350
        return contextutils.WrapErrorf(err, "failed to toggle auto-generation")
351
    }
352

353
    s.logger.Info(context.Background(), "Story auto-generation toggled",
354
        map[string]interface{}{
355
            "story_id": storyID,
356
            "user_id":  userID,
357
            "paused":   paused,
358
        })
359

360
    return nil
361
}
362

363
// SetCurrentStory sets a story as the current active story for the user in its language
364
func (s *StoryService) SetCurrentStory(ctx context.Context, storyID, userID uint) error {
365
    if err := s.validateStoryOwnership(ctx, storyID, userID); err != nil {
366
        return err
367
    }
368

369
    // Get the story's language and status
370
    query := "SELECT language, status FROM stories WHERE id = $1 AND user_id = $2"
371
    var language string
372
    var storyStatus models.StoryStatus
373
    err := s.db.QueryRowContext(ctx, query, storyID, userID).Scan(&language, &storyStatus)
374
    if err != nil {
375
        if errors.Is(err, sql.ErrNoRows) {
376
            return contextutils.ErrorWithContextf("story not found or access denied")
377
        }
378
        return contextutils.WrapErrorf(err, "failed to get story language and status")
379
    }
380

381
    // Only allow restoring active stories (not completed ones)
382
    if storyStatus == models.StoryStatusCompleted {
383
        return contextutils.ErrorWithContextf("cannot restore completed stories")
384
    }
385

386
    // Get the user's preferred language
387
    user, err := s.getUserByID(ctx, userID)
388
    if err != nil {
389
        return contextutils.WrapErrorf(err, "failed to get user")
390
    }
391

392
    if user == nil {
393
        return contextutils.ErrorWithContextf("user not found")
394
    }
395

396
    userPreferredLanguage := "en" // default
397
    if user.PreferredLanguage.Valid {
398
        userPreferredLanguage = user.PreferredLanguage.String
399
    }
400

401
    // Check if the story's language matches the user's preferred language
402
    if language != userPreferredLanguage {
403
        return contextutils.ErrorWithContextf("cannot restore story in different language than preferred language")
404
    }
405

406
    // Archive any existing active story in the same language for this user
407
    // (since only one story can be active per user per language)
408
    unsetQuery := "UPDATE stories SET status = $1, updated_at = NOW() WHERE user_id = $2 AND language = $3 AND status = $4"
409
    _, err = s.db.ExecContext(ctx, unsetQuery, models.StoryStatusArchived, userID, language, models.StoryStatusActive)
410
    if err != nil {
411
        return contextutils.WrapErrorf(err, "failed to unset existing active story")
412
    }
413

414
    // Set the specified story as active (which makes it current)
415
    setQuery := "UPDATE stories SET status = $1, updated_at = NOW() WHERE id = $2"
416
    _, err = s.db.ExecContext(ctx, setQuery, models.StoryStatusActive, storyID)
417
    if err != nil {
418
        return contextutils.WrapErrorf(err, "failed to set current story")
419
    }
420

421
    return nil
422
}
423

424
// FixCurrentStoryConstraint fixes any constraint violations where multiple stories are marked as active for the same user in the same language
425
func (s *StoryService) FixCurrentStoryConstraint(ctx context.Context) error {
426
    // Find all users who have multiple active stories in the same language
427
    query := `
428
        SELECT user_id, language, COUNT(*) as active_count
429
        FROM stories
430
        WHERE status = 'active'
431
        GROUP BY user_id, language
432
        HAVING COUNT(*) > 1`
433

434
    rows, err := s.db.QueryContext(ctx, query)
435
    if err != nil {
436
        return contextutils.WrapErrorf(err, "failed to find users with multiple active stories in same language")
437
    }
438
    defer func() { _ = rows.Close() }()
439

440
    for rows.Next() {
441
        var userID uint
442
        var language string
443
        var activeCount int
444

445
        if err := rows.Scan(&userID, &language, &activeCount); err != nil {
446
            return contextutils.WrapErrorf(err, "failed to scan user constraint violation")
447
        }
448

449
        // Fix constraint violation for this user and language
450
        if err := s.fixUserCurrentStoryConstraint(ctx, userID, language); err != nil {
451
            return contextutils.WrapErrorf(err, "failed to fix constraint for user %d in language %s", userID, language)
452
        }
453
    }
454

455
    return rows.Err()
456
}
457

458
// fixUserCurrentStoryConstraint fixes constraint violations for a specific user in a specific language
459
func (s *StoryService) fixUserCurrentStoryConstraint(ctx context.Context, userID uint, language string) error {
460
    tx, err := s.db.BeginTx(ctx, nil)
461
    if err != nil {
462
        return contextutils.WrapErrorf(err, "failed to begin transaction")
463
    }
464
    defer func() { _ = tx.Rollback() }()
465

466
    // Find all active stories for this user in this language, ordered by most recently updated
467
    var activeStories []uint
468
    selectQuery := `
469
        SELECT id FROM stories
470
        WHERE user_id = $1 AND language = $2 AND status = 'active'
471
        ORDER BY updated_at DESC`
472

473
    rows, err := tx.QueryContext(ctx, selectQuery, userID, language)
474
    if err != nil {
475
        return contextutils.WrapErrorf(err, "failed to find active stories for user in language")
476
    }
477
    defer func() { _ = rows.Close() }()
478

479
    for rows.Next() {
480
        var storyID uint
481
        if err := rows.Scan(&storyID); err != nil {
482
            return contextutils.WrapErrorf(err, "failed to scan story ID")
483
        }
484
        activeStories = append(activeStories, storyID)
485
    }
486

487
    if len(activeStories) <= 1 {
488
        // No constraint violation for this user in this language
489
        return tx.Commit()
490
    }
491

492
    // Archive all active stories except the most recently updated one
493
    for i := 1; i < len(activeStories); i++ {
494
        unsetQuery := "UPDATE stories SET status = $1, updated_at = NOW() WHERE id = $2"
495
        _, err = tx.ExecContext(ctx, unsetQuery, models.StoryStatusArchived, activeStories[i])
496
        if err != nil {
497
            return contextutils.WrapErrorf(err, "failed to unset active story %d", activeStories[i])
498
        }
499
    }
500

501
    return tx.Commit()
502
}
503

504
// DeleteStory permanently deletes a story (only allowed for archived stories)
505
func (s *StoryService) DeleteStory(ctx context.Context, storyID, userID uint) error {
506
    // Verify story exists and user owns it
507
    query := `
508
        SELECT id, user_id, title, language, subject, author_style, time_period, genre, tone,
509
               character_names, custom_instructions, section_length_override, status,
510
               last_section_generated_at, created_at, updated_at
511
        FROM stories
512
        WHERE id = $1 AND user_id = $2`
513

514
    var story models.Story
515
    err := s.db.QueryRowContext(ctx, query, storyID, userID).Scan(
516
        &story.ID, &story.UserID, &story.Title, &story.Language, &story.Subject,
517
        &story.AuthorStyle, &story.TimePeriod, &story.Genre, &story.Tone,
518
        &story.CharacterNames, &story.CustomInstructions, &story.SectionLengthOverride,
519
        &story.Status, &story.LastSectionGeneratedAt,
520
        &story.CreatedAt, &story.UpdatedAt,
521
    )
522
    if err != nil {
523
        if errors.Is(err, sql.ErrNoRows) {
524
            return contextutils.ErrorWithContextf("story not found or access denied")
525
        }
526
        return contextutils.WrapErrorf(err, "failed to get story")
527
    }
528

529
    // Only allow deletion of archived or completed stories
530
    if story.Status != models.StoryStatusArchived && story.Status != models.StoryStatusCompleted {
531
        return contextutils.ErrorWithContextf("cannot delete active story")
532
    }
533

534
    // Use transaction for atomic deletion
535
    tx, err := s.db.BeginTx(ctx, nil)
536
    if err != nil {
537
        return contextutils.WrapErrorf(err, "failed to begin transaction")
538
    }
539
    defer func() { _ = tx.Rollback() }()
540

541
    // Delete questions first (due to foreign key constraints)
542
    query1 := "DELETE FROM story_section_questions WHERE section_id IN (SELECT id FROM story_sections WHERE story_id = $1)"
543
    _, err = tx.ExecContext(ctx, query1, storyID)
544
    if err != nil {
545
        return contextutils.WrapErrorf(err, "failed to delete story questions")
546
    }
547

548
    // Delete sections
549
    query2 := "DELETE FROM story_sections WHERE story_id = $1"
550
    _, err = tx.ExecContext(ctx, query2, storyID)
551
    if err != nil {
552
        return contextutils.WrapErrorf(err, "failed to delete story sections")
553
    }
554

555
    // Delete story
556
    query3 := "DELETE FROM stories WHERE id = $1"
557
    _, err = tx.ExecContext(ctx, query3, storyID)
558
    if err != nil {
559
        return contextutils.WrapErrorf(err, "failed to delete story")
560
    }
561

562
    return tx.Commit()
563
}
564

565
// DeleteStoryAdmin permanently deletes a story by ID without ownership checks (admin-only).
566
// Admins can delete stories regardless of status, but regular users cannot delete active stories.
567
func (s *StoryService) DeleteStoryAdmin(ctx context.Context, storyID uint) error {
568
    // Verify story exists
569
    query := `
570
        SELECT id, user_id, title, language, subject, author_style, time_period, genre, tone,
571
               character_names, custom_instructions, section_length_override, status,
572
               last_section_generated_at, created_at, updated_at
573
        FROM stories
574
        WHERE id = $1`
575

576
    var story models.Story
577
    if err := s.db.QueryRowContext(ctx, query, storyID).Scan(
578
        &story.ID, &story.UserID, &story.Title, &story.Language, &story.Subject,
579
        &story.AuthorStyle, &story.TimePeriod, &story.Genre, &story.Tone,
580
        &story.CharacterNames, &story.CustomInstructions, &story.SectionLengthOverride,
581
        &story.Status, &story.LastSectionGeneratedAt,
582
        &story.CreatedAt, &story.UpdatedAt,
583
    ); err != nil {
584
        if errors.Is(err, sql.ErrNoRows) {
585
            return contextutils.ErrorWithContextf("story not found")
586
        }
587
        return contextutils.WrapErrorf(err, "failed to get story")
588
    }
589

590
    // Admin can delete any story regardless of status
591

592
    // Use transaction for atomic deletion
593
    tx, err := s.db.BeginTx(ctx, nil)
594
    if err != nil {
595
        return contextutils.WrapErrorf(err, "failed to begin transaction")
596
    }
597
    defer func() { _ = tx.Rollback() }()
598

599
    // Delete questions first (due to foreign key constraints)
600
    if _, err := tx.ExecContext(ctx, "DELETE FROM story_section_questions WHERE section_id IN (SELECT id FROM story_sections WHERE story_id = $1)", storyID); err != nil {
601
        return contextutils.WrapErrorf(err, "failed to delete story questions")
602
    }
603

604
    // Delete sections
605
    if _, err := tx.ExecContext(ctx, "DELETE FROM story_sections WHERE story_id = $1", storyID); err != nil {
606
        return contextutils.WrapErrorf(err, "failed to delete story sections")
607
    }
608

609
    // Delete story
610
    if _, err := tx.ExecContext(ctx, "DELETE FROM stories WHERE id = $1", storyID); err != nil {
611
        return contextutils.WrapErrorf(err, "failed to delete story")
612
    }
613

614
    return tx.Commit()
615
}
616

617
// DeleteAllStoriesForUser deletes all stories (and their sections/questions) for a given user
618
func (s *StoryService) DeleteAllStoriesForUser(ctx context.Context, userID uint) error {
619
    tx, err := s.db.BeginTx(ctx, nil)
620
    if err != nil {
621
        return contextutils.WrapErrorf(err, "failed to begin transaction")
622
    }
623
    defer func() { _ = tx.Rollback() }()
624

625
    // Delete questions for all sections belonging to stories of this user
626
    q1 := `DELETE FROM story_section_questions WHERE section_id IN (SELECT id FROM story_sections WHERE story_id IN (SELECT id FROM stories WHERE user_id = $1))`
627
    if _, err := tx.ExecContext(ctx, q1, userID); err != nil {
628
        return contextutils.WrapErrorf(err, "failed to delete story questions for user %d", userID)
629
    }
630

631
    // Delete sections for all stories belonging to this user
632
    q2 := `DELETE FROM story_sections WHERE story_id IN (SELECT id FROM stories WHERE user_id = $1)`
633
    if _, err := tx.ExecContext(ctx, q2, userID); err != nil {
634
        return contextutils.WrapErrorf(err, "failed to delete story sections for user %d", userID)
635
    }
636

637
    // Finally delete stories
638
    q3 := `DELETE FROM stories WHERE user_id = $1`
639
    if _, err := tx.ExecContext(ctx, q3, userID); err != nil {
640
        return contextutils.WrapErrorf(err, "failed to delete stories for user %d", userID)
641
    }
642

643
    if err := tx.Commit(); err != nil {
644
        return contextutils.WrapErrorf(err, "failed to commit delete all stories transaction for user %d", userID)
645
    }
646

647
    s.logger.Info(context.Background(), "Deleted all stories for user", map[string]interface{}{"user_id": userID})
648
    return nil
649
}
650

651
// GetStorySections retrieves all sections for a story
652
18x
func (s *StoryService) GetStorySections(ctx context.Context, storyID uint) ([]models.StorySection, error) {
653
18x
    query := `
654
18x
        SELECT id, story_id, section_number, content, language_level, word_count,
655
18x
               generated_by, generated_at, generation_date
656
18x
        FROM story_sections
657
18x
        WHERE story_id = $1
658
18x
        ORDER BY section_number ASC`
659
18x

660
18x
    rows, err := s.db.QueryContext(ctx, query, storyID)
661
18x
    if err != nil {
662
        return nil, contextutils.WrapErrorf(err, "failed to get story sections")
663
    }
664
18x
    defer func() { _ = rows.Close() }()
665

666
18x
    sections := make([]models.StorySection, 0)
667
18x
    for rows.Next() {
668
14x
        var section models.StorySection
669
14x
        err := rows.Scan(
670
14x
            &section.ID, &section.StoryID, &section.SectionNumber, &section.Content,
671
14x
            &section.LanguageLevel, &section.WordCount, &section.GeneratedBy, &section.GeneratedAt, &section.GenerationDate,
672
14x
        )
673
14x
        if err != nil {
674
            return nil, contextutils.WrapErrorf(err, "failed to scan story section")
675
        }
676
14x
        sections = append(sections, section)
677
    }
678

679
18x
    return sections, rows.Err()
680
}
681

682
// GetSection retrieves a specific section with ownership verification
683
3x
func (s *StoryService) GetSection(ctx context.Context, sectionID, userID uint) (*models.StorySectionWithQuestions, error) {
684
3x
    query := `
685
3x
        SELECT ss.id, ss.story_id, ss.section_number, ss.content, ss.language_level, ss.word_count,
686
3x
               ss.generated_by, ss.generated_at, ss.generation_date
687
3x
        FROM story_sections ss
688
3x
        JOIN stories s ON ss.story_id = s.id
689
3x
        WHERE ss.id = $1 AND s.user_id = $2`
690
3x

691
3x
    var section models.StorySection
692
3x
    err := s.db.QueryRowContext(ctx, query, sectionID, userID).Scan(
693
3x
        &section.ID, &section.StoryID, &section.SectionNumber, &section.Content,
694
3x
        &section.LanguageLevel, &section.WordCount, &section.GeneratedBy, &section.GeneratedAt, &section.GenerationDate,
695
3x
    )
696
3x
    if err != nil {
697
2x
        if errors.Is(err, sql.ErrNoRows) {
698
2x
            return nil, contextutils.ErrorWithContextf("section not found or access denied")
699
2x
        }
700
        return nil, contextutils.WrapErrorf(err, "failed to get section")
701
    }
702

703
    // Load questions
704
1x
    questions, err := s.GetSectionQuestions(ctx, section.ID)
705
1x
    if err != nil {
706
        return nil, contextutils.WrapErrorf(err, "failed to load section questions")
707
    }
708

709
1x
    return &models.StorySectionWithQuestions{
710
1x
        StorySection: section,
711
1x
        Questions:    questions,
712
1x
    }, nil
713
}
714

715
// CreateSection adds a new section to a story
716
22x
func (s *StoryService) CreateSection(ctx context.Context, storyID uint, content, level string, wordCount int, generatedBy models.GeneratorType) (*models.StorySection, error) {
717
22x
    // Get the next section number
718
22x
    var maxSectionNumber int
719
22x
    query := "SELECT COALESCE(MAX(section_number), 0) FROM story_sections WHERE story_id = $1"
720
22x
    err := s.db.QueryRowContext(ctx, query, storyID).Scan(&maxSectionNumber)
721
22x
    if err != nil {
722
        return nil, contextutils.WrapErrorf(err, "failed to get max section number")
723
    }
724

725
22x
    section := &models.StorySection{
726
22x
        StoryID:        storyID,
727
22x
        SectionNumber:  maxSectionNumber + 1,
728
22x
        Content:        content,
729
22x
        LanguageLevel:  level,
730
22x
        WordCount:      wordCount,
731
22x
        GeneratedBy:    generatedBy,
732
22x
        GeneratedAt:    time.Now(),
733
22x
        GenerationDate: time.Now().Truncate(24 * time.Hour),
734
22x
    }
735
22x

736
22x
    if err := s.createSection(ctx, section); err != nil {
737
        return nil, contextutils.WrapErrorf(err, "failed to create section")
738
    }
739

740
22x
    return section, nil
741
}
742

743
// GetLatestSection retrieves the most recent section for a story
744
3x
func (s *StoryService) GetLatestSection(ctx context.Context, storyID uint) (*models.StorySection, error) {
745
3x
    query := `
746
3x
        SELECT id, story_id, section_number, content, language_level, word_count,
747
3x
               generated_by, generated_at, generation_date
748
3x
        FROM story_sections
749
3x
        WHERE story_id = $1
750
3x
        ORDER BY section_number DESC
751
3x
        LIMIT 1`
752
3x

753
3x
    var section models.StorySection
754
3x
    err := s.db.QueryRowContext(ctx, query, storyID).Scan(
755
3x
        &section.ID, &section.StoryID, &section.SectionNumber, &section.Content,
756
3x
        &section.LanguageLevel, &section.WordCount, &section.GeneratedBy, &section.GeneratedAt, &section.GenerationDate,
757
3x
    )
758
3x
    if err != nil {
759
1x
        if errors.Is(err, sql.ErrNoRows) {
760
1x
            return nil, nil // No sections yet
761
1x
        }
762
        return nil, contextutils.WrapErrorf(err, "failed to get latest section")
763
    }
764

765
2x
    return &section, nil
766
}
767

768
// GetAllSectionsText concatenates all section content for AI context
769
func (s *StoryService) GetAllSectionsText(ctx context.Context, storyID uint) (string, error) {
770
    sections, err := s.GetStorySections(ctx, storyID)
771
    if err != nil {
772
        return "", err
773
    }
774

775
    var sectionsText strings.Builder
776
    for i, section := range sections {
777
        if i > 0 {
778
            sectionsText.WriteString("\n\n")
779
        }
780
        sectionsText.WriteString(fmt.Sprintf("Section %d:\n%s", section.SectionNumber, section.Content))
781
    }
782

783
    return sectionsText.String(), nil
784
}
785

786
// GetSectionQuestions retrieves all questions for a section
787
2x
func (s *StoryService) GetSectionQuestions(ctx context.Context, sectionID uint) ([]models.StorySectionQuestion, error) {
788
2x
    query := `
789
2x
        SELECT id, section_id, question_text, options, correct_answer_index, explanation, created_at
790
2x
        FROM story_section_questions
791
2x
        WHERE section_id = $1`
792
2x

793
2x
    rows, err := s.db.QueryContext(ctx, query, sectionID)
794
2x
    if err != nil {
795
        return nil, contextutils.WrapErrorf(err, "failed to get section questions")
796
    }
797
2x
    defer func() { _ = rows.Close() }()
798

799
2x
    questions := []models.StorySectionQuestion{}
800
2x
    for rows.Next() {
801
5x
        var question models.StorySectionQuestion
802
5x
        var optionsJSON []byte
803
5x

804
5x
        err := rows.Scan(
805
5x
            &question.ID, &question.SectionID, &question.QuestionText, &optionsJSON,
806
5x
            &question.CorrectAnswerIndex, &question.Explanation, &question.CreatedAt,
807
5x
        )
808
5x
        if err != nil {
809
            return nil, contextutils.WrapErrorf(err, "failed to scan question")
810
        }
811

812
        // Unmarshal JSON options back to []string
813
5x
        err = json.Unmarshal(optionsJSON, &question.Options)
814
5x
        if err != nil {
815
            return nil, contextutils.WrapErrorf(err, "failed to unmarshal options from JSON")
816
        }
817

818
5x
        questions = append(questions, question)
819
    }
820

821
2x
    return questions, rows.Err()
822
}
823

824
// CreateSectionQuestions bulk inserts questions for a section
825
2x
func (s *StoryService) CreateSectionQuestions(ctx context.Context, sectionID uint, questions []models.StorySectionQuestionData) error {
826
2x
    if len(questions) == 0 {
827
        return nil
828
    }
829

830
2x
    tx, err := s.db.BeginTx(ctx, nil)
831
2x
    if err != nil {
832
        return contextutils.WrapErrorf(err, "failed to begin transaction")
833
    }
834
2x
    defer func() { _ = tx.Rollback() }()
835

836
2x
    for _, q := range questions {
837
5x
        query := `
838
5x
            INSERT INTO story_section_questions (
839
5x
                section_id, question_text, options, correct_answer_index, explanation, created_at
840
5x
            ) VALUES ($1, $2, $3, $4, $5, $6)`
841
5x

842
5x
        // Convert []string options to JSON for PostgreSQL JSONB column
843
5x
        optionsJSON, err := json.Marshal(q.Options)
844
5x
        if err != nil {
845
            return contextutils.WrapErrorf(err, "failed to marshal options to JSON")
846
        }
847

848
5x
        _, err = tx.ExecContext(ctx, query,
849
5x
            sectionID, q.QuestionText, optionsJSON, q.CorrectAnswerIndex, q.Explanation, time.Now(),
850
5x
        )
851
5x
        if err != nil {
852
            return contextutils.WrapErrorf(err, "failed to insert question")
853
        }
854
    }
855

856
2x
    return tx.Commit()
857
}
858

859
// createSectionQuestionsInTx creates questions within an existing database transaction
860
func (s *StoryService) createSectionQuestionsInTx(ctx context.Context, tx *sql.Tx, sectionID uint, questions []models.StorySectionQuestionData) error {
861
    if len(questions) == 0 {
862
        return nil
863
    }
864

865
    for _, q := range questions {
866
        query := `
867
            INSERT INTO story_section_questions (
868
                section_id, question_text, options, correct_answer_index, explanation, created_at
869
            ) VALUES ($1, $2, $3, $4, $5, $6)`
870

871
        // Convert []string options to JSON for PostgreSQL JSONB column
872
        optionsJSON, err := json.Marshal(q.Options)
873
        if err != nil {
874
            return contextutils.WrapErrorf(err, "failed to marshal options to JSON")
875
        }
876

877
        _, err = tx.ExecContext(ctx, query,
878
            sectionID, q.QuestionText, optionsJSON, q.CorrectAnswerIndex, q.Explanation, time.Now(),
879
        )
880
        if err != nil {
881
            return contextutils.WrapErrorf(err, "failed to insert question")
882
        }
883
    }
884

885
    return nil
886
}
887

888
// GetRandomQuestions retrieves N random questions for a section
889
1x
func (s *StoryService) GetRandomQuestions(ctx context.Context, sectionID uint, count int) ([]models.StorySectionQuestion, error) {
890
1x
    query := `
891
1x
        SELECT id, section_id, question_text, options, correct_answer_index, explanation, created_at
892
1x
        FROM story_section_questions
893
1x
        WHERE section_id = $1
894
1x
        ORDER BY RANDOM()
895
1x
        LIMIT $2`
896
1x

897
1x
    rows, err := s.db.QueryContext(ctx, query, sectionID, count)
898
1x
    if err != nil {
899
        return nil, contextutils.WrapErrorf(err, "failed to get random questions")
900
    }
901
1x
    defer func() { _ = rows.Close() }()
902

903
1x
    questions := []models.StorySectionQuestion{}
904
1x
    for rows.Next() {
905
2x
        var question models.StorySectionQuestion
906
2x
        var optionsJSON []byte
907
2x

908
2x
        err := rows.Scan(
909
2x
            &question.ID, &question.SectionID, &question.QuestionText, &optionsJSON,
910
2x
            &question.CorrectAnswerIndex, &question.Explanation, &question.CreatedAt,
911
2x
        )
912
2x
        if err != nil {
913
            return nil, contextutils.WrapErrorf(err, "failed to scan question")
914
        }
915

916
        // Unmarshal JSON options back to []string
917
2x
        err = json.Unmarshal(optionsJSON, &question.Options)
918
2x
        if err != nil {
919
            return nil, contextutils.WrapErrorf(err, "failed to unmarshal options from JSON")
920
        }
921

922
2x
        questions = append(questions, question)
923
    }
924

925
1x
    return questions, rows.Err()
926
}
927

928
// canGenerateSection checks if a new section can be generated for a story today by a specific generator
929
19x
func (s *StoryService) canGenerateSection(ctx context.Context, storyID uint, generatorType models.GeneratorType) (response *models.StoryGenerationEligibilityResponse, err error) {
930
19x
    ctx, span := observability.TraceFunction(ctx, "story_service", "can_generate_section",
931
19x
        attribute.Int("story.id", int(storyID)),
932
19x
        observability.AttributeGenerationType(generatorType),
933
19x
    )
934
19x
    defer observability.FinishSpan(span, &err)
935
19x

936
19x
    query := `
937
19x
        SELECT status, last_section_generated_at, extra_generations_today
938
19x
        FROM stories
939
19x
        WHERE id = $1`
940
19x

941
19x
    var status string
942
19x
    var lastGen *time.Time
943
19x
    var extraGenerationsToday int
944
19x

945
19x
    err = s.db.QueryRowContext(ctx, query, storyID).Scan(&status, &lastGen, &extraGenerationsToday)
946
19x
    if err != nil {
947
        if errors.Is(err, sql.ErrNoRows) {
948
            return &models.StoryGenerationEligibilityResponse{
949
                CanGenerate: false,
950
                Reason:      "story not found",
951
            }, nil
952
        }
953
        return nil, contextutils.WrapErrorf(err, "failed to get story")
954
    }
955

956
    // Check if story generation is enabled globally
957
19x
    if !s.config.Story.GenerationEnabled {
958
        return &models.StoryGenerationEligibilityResponse{
959
            CanGenerate: false,
960
            Reason:      "story generation is disabled globally",
961
        }, nil
962
    }
963

964
    // Check if story is active (active stories are by definition current)
965
19x
    if status != string(models.StoryStatusActive) {
966
        return &models.StoryGenerationEligibilityResponse{
967
            CanGenerate: false,
968
            Reason:      "story is not active",
969
        }, nil
970
    }
971

972
    // Check engagement-based generation if enabled and this is worker generation
973
    // Manual user generation should always be allowed regardless of engagement
974
19x
    if s.config.Story.EngagementBasedGeneration && generatorType == models.GeneratorTypeWorker {
975
3x
        // Get the user ID for this story to check engagement
976
3x
        userIDQuery := "SELECT user_id FROM stories WHERE id = $1"
977
3x
        var userID uint
978
3x
        err = s.db.QueryRowContext(ctx, userIDQuery, storyID).Scan(&userID)
979
3x
        if err != nil {
980
            return nil, contextutils.WrapErrorf(err, "failed to get user ID for story")
981
        }
982

983
        // Check if user has viewed the latest section
984
3x
        hasViewedLatest, err := s.HasUserViewedLatestSection(ctx, userID)
985
3x
        if err != nil {
986
            return nil, contextutils.WrapErrorf(err, "failed to check user engagement")
987
        }
988
3x
        if !hasViewedLatest {
989
1x
            return &models.StoryGenerationEligibilityResponse{
990
1x
                CanGenerate: false,
991
1x
                Reason:      "user has not viewed the latest section",
992
1x
            }, nil
993
1x
        }
994
    }
995

996
    // Check generation count for today by generator type
997
18x
    today := time.Now().Truncate(24 * time.Hour)
998
18x
    var sectionCount int
999
18x
    sectionQuery := `
1000
18x
        SELECT COUNT(*)
1001
18x
        FROM story_sections
1002
18x
        WHERE story_id = $1 AND generation_date = $2 AND generated_by = $3`
1003
18x

1004
18x
    err = s.db.QueryRowContext(ctx, sectionQuery, storyID, today, generatorType).Scan(&sectionCount)
1005
18x
    if err != nil {
1006
        return nil, contextutils.WrapErrorf(err, "failed to check existing sections today by generator type")
1007
    }
1008
18x
    span.SetAttributes(attribute.Int(fmt.Sprintf("section_count_%s", generatorType), sectionCount))
1009
18x
    span.SetAttributes(attribute.Int("max_worker_generations_per_day", s.config.Story.MaxWorkerGenerationsPerDay))
1010
18x
    span.SetAttributes(attribute.Int("max_user_generations_per_day", s.config.Story.MaxExtraGenerationsPerDay))
1011
18x

1012
18x
    // Check limits based on generator type
1013
18x
    switch generatorType {
1014
5x
    case models.GeneratorTypeWorker:
1015
5x
        // Worker can generate MaxWorkerGenerationsPerDay sections per day
1016
5x
        if sectionCount >= s.config.Story.MaxWorkerGenerationsPerDay {
1017
            return &models.StoryGenerationEligibilityResponse{
1018
                CanGenerate: false,
1019
                Reason:      fmt.Sprintf("worker has reached daily generation limit (%d)", s.config.Story.MaxWorkerGenerationsPerDay),
1020
            }, nil
1021
        }
1022
13x
    case models.GeneratorTypeUser:
1023
13x
        if sectionCount >= s.config.Story.MaxExtraGenerationsPerDay {
1024
5x
            return &models.StoryGenerationEligibilityResponse{
1025
5x
                CanGenerate: false,
1026
5x
                Reason:      fmt.Sprintf("user has reached daily generation limit (%d)", s.config.Story.MaxExtraGenerationsPerDay),
1027
5x
            }, nil
1028
5x
        }
1029
    default:
1030
        return &models.StoryGenerationEligibilityResponse{
1031
            CanGenerate: false,
1032
            Reason:      "invalid generator type",
1033
        }, nil
1034
    }
1035

1036
    // Allow generation if within limits
1037
13x
    return &models.StoryGenerationEligibilityResponse{
1038
13x
        CanGenerate: true,
1039
13x
    }, nil
1040
}
1041

1042
// UpdateLastGenerationTime sets the last section generation time for a story
1043
12x
func (s *StoryService) UpdateLastGenerationTime(ctx context.Context, storyID uint, generatorType models.GeneratorType) (err error) {
1044
12x
    ctx, span := observability.TraceFunction(ctx, "story_service", "update_last_generation_time",
1045
12x
        attribute.Int("story.id", int(storyID)),
1046
12x
        observability.AttributeGenerationType(generatorType),
1047
12x
    )
1048
12x
    defer observability.FinishSpan(span, &err)
1049
12x
    // Check if this is an extra generation (second generation today)
1050
12x
    query := `
1051
12x
        SELECT last_section_generated_at, extra_generations_today
1052
12x
        FROM stories
1053
12x
        WHERE id = $1`
1054
12x

1055
12x
    var lastGen *time.Time
1056
12x
    var extraGenerationsToday int
1057
12x

1058
12x
    err = s.db.QueryRowContext(ctx, query, storyID).Scan(&lastGen, &extraGenerationsToday)
1059
12x
    if err != nil {
1060
        return contextutils.WrapErrorf(err, "failed to get current generation info")
1061
    }
1062

1063
12x
    now := time.Now()
1064
12x

1065
12x
    // Check if we already generated today and update accordingly
1066
12x
    if lastGen != nil {
1067
8x
        lastGenTime := lastGen.Truncate(24 * time.Hour)
1068
8x
        today := now.Truncate(24 * time.Hour)
1069
8x
        if lastGenTime.Equal(today) {
1070
8x
            // Only increment counter for user generations
1071
8x
            if generatorType == models.GeneratorTypeUser {
1072
7x
                maxTotal := s.config.Story.MaxExtraGenerationsPerDay + 1
1073
7x
                if extraGenerationsToday < maxTotal {
1074
7x
                    updateQuery := "UPDATE stories SET extra_generations_today = extra_generations_today + 1, last_section_generated_at = $1, updated_at = NOW() WHERE id = $2"
1075
7x
                    _, err = s.db.ExecContext(ctx, updateQuery, now, storyID)
1076
7x
                    if err != nil {
1077
                        return contextutils.WrapErrorf(err, "failed to update generation time")
1078
                    }
1079
                } else {
1080
                    // Limit reached - just update timestamp
1081
                    updateQuery := "UPDATE stories SET last_section_generated_at = $1, updated_at = NOW() WHERE id = $2"
1082
                    _, err = s.db.ExecContext(ctx, updateQuery, now, storyID)
1083
                    if err != nil {
1084
                        return contextutils.WrapErrorf(err, "failed to update generation time")
1085
                    }
1086
                }
1087
1x
            } else {
1088
1x
                // Worker generation - just update timestamp
1089
1x
                updateQuery := "UPDATE stories SET last_section_generated_at = $1, updated_at = NOW() WHERE id = $2"
1090
1x
                _, err = s.db.ExecContext(ctx, updateQuery, now, storyID)
1091
1x
                if err != nil {
1092
                    return contextutils.WrapErrorf(err, "failed to update generation time")
1093
                }
1094
            }
1095
8x
            return nil
1096
        }
1097
    }
1098

1099
    // First generation today - only increment counter for user generations
1100
4x
    if generatorType == models.GeneratorTypeUser {
1101
2x
        updateQuery := "UPDATE stories SET extra_generations_today = extra_generations_today + 1, last_section_generated_at = $1, updated_at = NOW() WHERE id = $2"
1102
2x
        _, err = s.db.ExecContext(ctx, updateQuery, now, storyID)
1103
2x
        if err != nil {
1104
            return contextutils.WrapErrorf(err, "failed to update generation time for first generation")
1105
        }
1106
2x
    } else {
1107
2x
        // Worker generation - just update timestamp
1108
2x
        updateQuery := "UPDATE stories SET last_section_generated_at = $1, updated_at = NOW() WHERE id = $2"
1109
2x
        _, err = s.db.ExecContext(ctx, updateQuery, now, storyID)
1110
2x
        if err != nil {
1111
            return contextutils.WrapErrorf(err, "failed to update generation time for first generation")
1112
        }
1113
    }
1114

1115
4x
    return nil
1116
}
1117

1118
// RecordStorySectionView records that a user has viewed a story section
1119
6x
func (s *StoryService) RecordStorySectionView(ctx context.Context, userID, sectionID uint) (err error) {
1120
6x
    ctx, span := observability.TraceFunction(ctx, "story_service", "record_section_view",
1121
6x
        observability.AttributeUserID(int(userID)),
1122
6x
        attribute.Int("section.id", int(sectionID)),
1123
6x
    )
1124
6x
    defer observability.FinishSpan(span, &err)
1125
6x

1126
6x
    // Use UPSERT to either insert a new view or update the viewed_at timestamp if the view already exists
1127
6x
    query := `
1128
6x
        INSERT INTO story_section_views (user_id, section_id, viewed_at, created_at)
1129
6x
        VALUES ($1, $2, NOW(), NOW())
1130
6x
        ON CONFLICT (user_id, section_id)
1131
6x
        DO UPDATE SET viewed_at = NOW()`
1132
6x

1133
6x
    _, err = s.db.ExecContext(ctx, query, userID, sectionID)
1134
6x
    if err != nil {
1135
        return contextutils.WrapErrorf(err, "failed to record story section view")
1136
    }
1137

1138
6x
    return nil
1139
}
1140

1141
// HasUserViewedLatestSection checks if a user has viewed the latest section of their story
1142
12x
func (s *StoryService) HasUserViewedLatestSection(ctx context.Context, userID uint) (bool, error) {
1143
12x
    ctx, span := observability.TraceFunction(ctx, "story_service", "has_user_viewed_latest_section",
1144
12x
        observability.AttributeUserID(int(userID)),
1145
12x
    )
1146
12x
    defer observability.FinishSpan(span, nil)
1147
12x

1148
12x
    // Get the user's current active story
1149
12x
    story, err := s.GetCurrentStory(ctx, userID)
1150
12x
    if err != nil {
1151
        return false, contextutils.WrapErrorf(err, "failed to get current story")
1152
    }
1153
12x
    if story == nil {
1154
1x
        // No current story - can't generate anything
1155
1x
        return false, nil
1156
1x
    }
1157
11x
    if len(story.Sections) == 0 {
1158
3x
        // Story exists but has no sections yet - allow first section generation
1159
3x
        return true, nil
1160
3x
    }
1161

1162
    // Get the latest section (highest section number)
1163
8x
    latestSection := story.Sections[len(story.Sections)-1]
1164
8x

1165
8x
    // Check if user has viewed this section
1166
8x
    query := `
1167
8x
        SELECT EXISTS(
1168
8x
            SELECT 1 FROM story_section_views
1169
8x
            WHERE user_id = $1 AND section_id = $2
1170
8x
        )`
1171
8x

1172
8x
    var hasViewed bool
1173
8x
    err = s.db.QueryRowContext(ctx, query, userID, latestSection.ID).Scan(&hasViewed)
1174
8x
    if err != nil {
1175
        return false, contextutils.WrapErrorf(err, "failed to check if user viewed latest section")
1176
    }
1177

1178
8x
    return hasViewed, nil
1179
}
1180

1181
// Helper methods
1182

1183
// getUserByID retrieves a user by their ID
1184
22x
func (s *StoryService) getUserByID(ctx context.Context, userID uint) (*models.User, error) {
1185
22x
    query := "SELECT id, username, email, preferred_language, current_level, ai_provider, ai_model, ai_api_key, created_at, updated_at FROM users WHERE id = $1"
1186
22x

1187
22x
    var user models.User
1188
22x
    err := s.db.QueryRowContext(ctx, query, userID).Scan(
1189
22x
        &user.ID, &user.Username, &user.Email, &user.PreferredLanguage,
1190
22x
        &user.CurrentLevel, &user.AIProvider, &user.AIModel, &user.AIAPIKey,
1191
22x
        &user.CreatedAt, &user.UpdatedAt,
1192
22x
    )
1193
22x
    if err != nil {
1194
1x
        if errors.Is(err, sql.ErrNoRows) {
1195
1x
            return nil, nil // User not found
1196
1x
        }
1197
        return nil, contextutils.WrapErrorf(err, "failed to get user")
1198
    }
1199

1200
21x
    return &user, nil
1201
}
1202

1203
// getArchivedStoryCount counts archived stories for a user
1204
19x
func (s *StoryService) getArchivedStoryCount(ctx context.Context, userID uint) (int, error) {
1205
19x
    query := "SELECT COUNT(*) FROM stories WHERE user_id = $1 AND status = $2"
1206
19x
    var count int
1207
19x
    err := s.db.QueryRowContext(ctx, query, userID, models.StoryStatusArchived).Scan(&count)
1208
19x
    if err != nil {
1209
        return 0, err
1210
    }
1211

1212
19x
    return count, nil
1213
}
1214

1215
// getUserCurrentLevel retrieves the user's current language level
1216
19x
func (s *StoryService) getUserCurrentLevel(ctx context.Context, userID uint) (string, error) {
1217
19x
    query := "SELECT current_level FROM users WHERE id = $1"
1218
19x
    var level sql.NullString
1219
19x
    err := s.db.QueryRowContext(ctx, query, userID).Scan(&level)
1220
19x
    if err != nil {
1221
        return "", contextutils.WrapErrorf(err, "failed to get user")
1222
    }
1223

1224
19x
    if !level.Valid {
1225
        return "", contextutils.ErrorWithContextf("user has no current level set")
1226
    }
1227

1228
19x
    return level.String, nil
1229
}
1230

1231
// validateStoryOwnership verifies that a user owns a story
1232
2x
func (s *StoryService) validateStoryOwnership(ctx context.Context, storyID, userID uint) error {
1233
2x
    query := "SELECT COUNT(*) FROM stories WHERE id = $1 AND user_id = $2"
1234
2x
    var count int
1235
2x
    err := s.db.QueryRowContext(ctx, query, storyID, userID).Scan(&count)
1236
2x
    if err != nil {
1237
        return contextutils.WrapErrorf(err, "failed to validate story ownership")
1238
    }
1239

1240
2x
    if count == 0 {
1241
        return contextutils.ErrorWithContextf("story not found or access denied")
1242
    }
1243

1244
2x
    return nil
1245
}
1246

1247
// GetSectionLengthTarget returns the target word count for a story section
1248
func (s *StoryService) GetSectionLengthTarget(level string, lengthPref *models.SectionLength) int {
1249
    return models.GetSectionLengthTarget(level, lengthPref)
1250
}
1251

1252
// GetSectionLengthTargetWithLanguage returns the target word count with language-specific overrides
1253
func (s *StoryService) GetSectionLengthTargetWithLanguage(language, level string, lengthPref *models.SectionLength) int {
1254
    // Check for language-specific overrides in config
1255
    if languageOverrides, exists := s.config.Story.SectionLengths.Overrides[language]; exists {
1256
        if levelTargets, exists := languageOverrides[level]; exists {
1257
            // Use the override if it exists for this level
1258
            if lengthPref != nil {
1259
                if target, exists := levelTargets[string(*lengthPref)]; exists {
1260
                    return target
1261
                }
1262
            }
1263
            // Default to medium if no specific length preference
1264
            if target, exists := levelTargets["medium"]; exists {
1265
                return target
1266
            }
1267
        }
1268
    }
1269

1270
    // Fall back to the default implementation
1271
    return models.GetSectionLengthTarget(level, lengthPref)
1272
}
1273

1274
// SanitizeInput sanitizes user input for safe use in AI prompts
1275
func (s *StoryService) SanitizeInput(input string) string {
1276
    return models.SanitizeInput(input)
1277
}
1278

1279
// Database helper methods using sql.DB
1280

1281
// createStory inserts a new story into the database
1282
19x
func (s *StoryService) createStory(ctx context.Context, story *models.Story) error {
1283
19x
    query := `
1284
19x
        INSERT INTO stories (
1285
19x
            user_id, title, language, subject, author_style, time_period, genre, tone,
1286
19x
            character_names, custom_instructions, section_length_override, status,
1287
19x
            created_at, updated_at
1288
19x
        ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
1289
19x
        RETURNING id`
1290
19x

1291
19x
    err := s.db.QueryRowContext(ctx, query,
1292
19x
        story.UserID, story.Title, story.Language, story.Subject, story.AuthorStyle,
1293
19x
        story.TimePeriod, story.Genre, story.Tone, story.CharacterNames,
1294
19x
        story.CustomInstructions, story.SectionLengthOverride, story.Status,
1295
19x
        story.CreatedAt, story.UpdatedAt,
1296
19x
    ).Scan(&story.ID)
1297
19x

1298
19x
    return err
1299
19x
}
1300

1301
// Admin-only methods (no ownership checks)
1302

1303
// GetStoriesPaginated returns stories with optional filters for admin views
1304
func (s *StoryService) GetStoriesPaginated(ctx context.Context, page, pageSize int, search, language, status string, userID *uint) ([]models.Story, int, error) {
1305
    if page <= 0 {
1306
        page = 1
1307
    }
1308
    if pageSize <= 0 || pageSize > 100 {
1309
        pageSize = 20
1310
    }
1311

1312
    // Build WHERE clauses dynamically
1313
    where := []string{"1=1"}
1314
    args := []interface{}{}
1315
    argIdx := 1
1316
    if search != "" {
1317
        where = append(where, fmt.Sprintf("(LOWER(title) LIKE $%d)", argIdx))
1318
        args = append(args, "%"+strings.ToLower(search)+"%")
1319
        argIdx++
1320
    }
1321
    if language != "" {
1322
        where = append(where, fmt.Sprintf("language = $%d", argIdx))
1323
        args = append(args, language)
1324
        argIdx++
1325
    }
1326
    if status != "" {
1327
        where = append(where, fmt.Sprintf("status = $%d", argIdx))
1328
        args = append(args, status)
1329
        argIdx++
1330
    }
1331
    if userID != nil {
1332
        where = append(where, fmt.Sprintf("user_id = $%d", argIdx))
1333
        args = append(args, *userID)
1334
        argIdx++
1335
    }
1336

1337
    whereClause := strings.Join(where, " AND ")
1338

1339
    // Count total
1340
    countQuery := "SELECT COUNT(*) FROM stories WHERE " + whereClause
1341
    var total int
1342
    if err := s.db.QueryRowContext(ctx, countQuery, args...).Scan(&total); err != nil {
1343
        return nil, 0, contextutils.WrapErrorf(err, "failed to count stories")
1344
    }
1345

1346
    // Fetch rows
1347
    offset := (page - 1) * pageSize
1348
    listQuery := `
1349
        SELECT id, user_id, title, language, subject, author_style, time_period, genre, tone,
1350
               character_names, custom_instructions, section_length_override, status,
1351
               last_section_generated_at, created_at, updated_at
1352
        FROM stories
1353
        WHERE ` + whereClause + `
1354
        ORDER BY created_at DESC
1355
        LIMIT $` + fmt.Sprint(argIdx) + ` OFFSET $` + fmt.Sprint(argIdx+1)
1356

1357
    args = append(args, pageSize, offset)
1358

1359
    rows, err := s.db.QueryContext(ctx, listQuery, args...)
1360
    if err != nil {
1361
        return nil, 0, contextutils.WrapErrorf(err, "failed to query stories")
1362
    }
1363
    defer func() { _ = rows.Close() }()
1364

1365
    stories := []models.Story{}
1366
    for rows.Next() {
1367
        var story models.Story
1368
        if err := rows.Scan(
1369
            &story.ID, &story.UserID, &story.Title, &story.Language, &story.Subject,
1370
            &story.AuthorStyle, &story.TimePeriod, &story.Genre, &story.Tone,
1371
            &story.CharacterNames, &story.CustomInstructions, &story.SectionLengthOverride,
1372
            &story.Status, &story.LastSectionGeneratedAt,
1373
            &story.CreatedAt, &story.UpdatedAt,
1374
        ); err != nil {
1375
            return nil, 0, contextutils.WrapErrorf(err, "failed to scan story")
1376
        }
1377
        stories = append(stories, story)
1378
    }
1379

1380
    return stories, total, rows.Err()
1381
}
1382

1383
// GetStoryAdmin returns story with sections for admin (no ownership checks)
1384
func (s *StoryService) GetStoryAdmin(ctx context.Context, storyID uint) (*models.StoryWithSections, error) {
1385
    query := `
1386
        SELECT id, user_id, title, language, subject, author_style, time_period, genre, tone,
1387
               character_names, custom_instructions, section_length_override, status,
1388
               last_section_generated_at, created_at, updated_at
1389
        FROM stories
1390
        WHERE id = $1`
1391

1392
    var story models.Story
1393
    if err := s.db.QueryRowContext(ctx, query, storyID).Scan(
1394
        &story.ID, &story.UserID, &story.Title, &story.Language, &story.Subject,
1395
        &story.AuthorStyle, &story.TimePeriod, &story.Genre, &story.Tone,
1396
        &story.CharacterNames, &story.CustomInstructions, &story.SectionLengthOverride,
1397
        &story.Status, &story.LastSectionGeneratedAt,
1398
        &story.CreatedAt, &story.UpdatedAt,
1399
    ); err != nil {
1400
        if errors.Is(err, sql.ErrNoRows) {
1401
            return nil, contextutils.ErrorWithContextf("story not found")
1402
        }
1403
        return nil, contextutils.WrapErrorf(err, "failed to get story")
1404
    }
1405

1406
    sections, err := s.GetStorySections(ctx, story.ID)
1407
    if err != nil {
1408
        return nil, contextutils.WrapErrorf(err, "failed to load story sections")
1409
    }
1410

1411
    return &models.StoryWithSections{Story: story, Sections: sections}, nil
1412
}
1413

1414
// GetSectionAdmin returns section with questions for admin (no ownership checks)
1415
func (s *StoryService) GetSectionAdmin(ctx context.Context, sectionID uint) (*models.StorySectionWithQuestions, error) {
1416
    query := `
1417
        SELECT id, story_id, section_number, content, language_level, word_count,
1418
               generated_by, generated_at, generation_date
1419
        FROM story_sections
1420
        WHERE id = $1`
1421

1422
    var section models.StorySection
1423
    if err := s.db.QueryRowContext(ctx, query, sectionID).Scan(
1424
        &section.ID, &section.StoryID, &section.SectionNumber, &section.Content,
1425
        &section.LanguageLevel, &section.WordCount, &section.GeneratedBy, &section.GeneratedAt, &section.GenerationDate,
1426
    ); err != nil {
1427
        if errors.Is(err, sql.ErrNoRows) {
1428
            return nil, contextutils.ErrorWithContextf("section not found")
1429
        }
1430
        return nil, contextutils.WrapErrorf(err, "failed to get section")
1431
    }
1432

1433
    questions, err := s.GetSectionQuestions(ctx, section.ID)
1434
    if err != nil {
1435
        return nil, contextutils.WrapErrorf(err, "failed to load section questions")
1436
    }
1437

1438
    return &models.StorySectionWithQuestions{StorySection: section, Questions: questions}, nil
1439
}
1440

1441
// createSection inserts a new section into the database
1442
22x
func (s *StoryService) createSection(ctx context.Context, section *models.StorySection) error {
1443
22x
    query := `
1444
22x
        INSERT INTO story_sections (
1445
22x
            story_id, section_number, content, language_level, word_count, generated_by,
1446
22x
            generated_at, generation_date
1447
22x
        ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
1448
22x
        RETURNING id`
1449
22x

1450
22x
    err := s.db.QueryRowContext(ctx, query,
1451
22x
        section.StoryID, section.SectionNumber, section.Content, section.LanguageLevel,
1452
22x
        section.WordCount, section.GeneratedBy, section.GeneratedAt, section.GenerationDate,
1453
22x
    ).Scan(&section.ID)
1454
22x

1455
22x
    return err
1456
22x
}
1457

1458
// createSectionInTx creates a section within an existing database transaction
1459
func (s *StoryService) createSectionInTx(ctx context.Context, tx *sql.Tx, section *models.StorySection) error {
1460
    query := `
1461
        INSERT INTO story_sections (
1462
            story_id, section_number, content, language_level, word_count, generated_by,
1463
            generated_at, generation_date
1464
        ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
1465
        RETURNING id`
1466

1467
    err := tx.QueryRowContext(ctx, query,
1468
        section.StoryID, section.SectionNumber, section.Content, section.LanguageLevel,
1469
        section.WordCount, section.GeneratedBy, section.GeneratedAt, section.GenerationDate,
1470
    ).Scan(&section.ID)
1471

1472
    return err
1473
}
1474

1475
// GenerateStorySection generates a new section for a story using AI
1476
func (s *StoryService) GenerateStorySection(ctx context.Context, storyID, userID uint, aiService AIServiceInterface, userAIConfig *models.UserAIConfig, generatorType models.GeneratorType) (*models.StorySectionWithQuestions, error) {
1477
    ctx, span := observability.TraceFunction(ctx, "story_service", "generate_section",
1478
        attribute.Int("story.id", int(storyID)),
1479
        observability.AttributeUserID(int(userID)),
1480
        observability.AttributeGenerationType(generatorType),
1481
        attribute.String("model", userAIConfig.Model),
1482
        attribute.String("provider", userAIConfig.Provider),
1483
        attribute.String("username", userAIConfig.Username),
1484
    )
1485
    defer observability.FinishSpan(span, nil)
1486

1487
    // Get the story to verify ownership and get details
1488
    story, err := s.GetStory(ctx, storyID, userID)
1489
    if err != nil {
1490
        return nil, contextutils.WrapErrorf(err, "failed to get story for generation")
1491
    }
1492
    span.SetAttributes(attribute.String("story.title", story.Title))
1493
    span.SetAttributes(attribute.String("story.language", story.Language))
1494
    span.SetAttributes(attribute.String("story.section_length_override", story.GetSectionLengthOverride()))
1495
    span.SetAttributes(attribute.String("story.subject", stringPtrToString(story.Subject)))
1496
    span.SetAttributes(attribute.String("story.author_style", stringPtrToString(story.AuthorStyle)))
1497
    span.SetAttributes(attribute.String("story.time_period", stringPtrToString(story.TimePeriod)))
1498
    span.SetAttributes(attribute.String("story.genre", stringPtrToString(story.Genre)))
1499
    span.SetAttributes(attribute.String("story.tone", stringPtrToString(story.Tone)))
1500
    span.SetAttributes(attribute.String("story.character_names", stringPtrToString(story.CharacterNames)))
1501
    span.SetAttributes(attribute.String("story.custom_instructions", stringPtrToString(story.CustomInstructions)))
1502

1503
    // Check if generation is allowed today by this generator type
1504
    eligibility, err := s.canGenerateSection(ctx, storyID, generatorType)
1505
    if err != nil {
1506
        return nil, contextutils.WrapErrorf(err, "failed to check generation eligibility")
1507
    }
1508
    if !eligibility.CanGenerate {
1509
        return nil, contextutils.WrapError(contextutils.ErrGenerationLimitReached, eligibility.Reason)
1510
    }
1511

1512
    // Get user for AI configuration and language preferences
1513
    user, err := s.getUserByID(ctx, userID)
1514
    if err != nil {
1515
        return nil, contextutils.WrapErrorf(err, "failed to get user")
1516
    }
1517
    if user == nil {
1518
        return nil, contextutils.ErrorWithContextf("user not found")
1519
    }
1520

1521
    // Get all previous sections for context
1522
    previousSections, err := s.GetAllSectionsText(ctx, storyID)
1523
    if err != nil {
1524
        return nil, contextutils.WrapErrorf(err, "failed to get previous sections")
1525
    }
1526

1527
    // Get the user's current language level (handle sql.NullString)
1528
    if !user.CurrentLevel.Valid {
1529
        return nil, contextutils.ErrorWithContextf("user level not found")
1530
    }
1531
    span.SetAttributes(attribute.String("story.level", user.CurrentLevel.String))
1532

1533
    // Determine target length for this user's level
1534
    targetWords := s.GetSectionLengthTarget(user.CurrentLevel.String, story.SectionLengthOverride)
1535

1536
    // Build the generation request
1537
    genReq := &models.StoryGenerationRequest{
1538
        UserID:             userID,
1539
        StoryID:            storyID,
1540
        Language:           story.Language,
1541
        Level:              user.CurrentLevel.String,
1542
        Title:              story.Title,
1543
        Subject:            story.Subject,
1544
        AuthorStyle:        story.AuthorStyle,
1545
        TimePeriod:         story.TimePeriod,
1546
        Genre:              story.Genre,
1547
        Tone:               story.Tone,
1548
        CharacterNames:     story.CharacterNames,
1549
        CustomInstructions: story.CustomInstructions,
1550
        SectionLength:      models.SectionLengthMedium, // Use medium as default
1551
        PreviousSections:   previousSections,
1552
        IsFirstSection:     len(story.Sections) == 0,
1553
        TargetWords:        targetWords,
1554
        TargetSentences:    targetWords / 15, // Rough estimate
1555
    }
1556

1557
    // Generate the story section using AI
1558
    sectionContent, err := aiService.GenerateStorySection(ctx, userAIConfig, genReq)
1559
    if err != nil {
1560
        // Check if this is a context cancellation error
1561
        if ctx.Err() == context.DeadlineExceeded {
1562
            s.logger.Error(ctx, "Story section generation timed out", err, map[string]interface{}{
1563
                "story_id": storyID,
1564
                "user_id":  userID,
1565
            })
1566
            return nil, contextutils.WrapErrorf(contextutils.ErrTimeout, "story generation timed out: %w", err)
1567
        }
1568
        return nil, contextutils.WrapErrorf(err, "failed to generate story section")
1569
    }
1570

1571
    // Count words in the generated content
1572
    wordCount := len(strings.Fields(sectionContent))
1573

1574
    // Start a database transaction to ensure atomicity of section and questions creation
1575
    tx, err := s.db.BeginTx(ctx, nil)
1576
    if err != nil {
1577
        return nil, contextutils.WrapErrorf(err, "failed to begin transaction")
1578
    }
1579
    span.AddEvent("transaction_began")
1580

1581
    var committed bool
1582
    defer func() {
1583
        if !committed {
1584
            if rollbackErr := tx.Rollback(); rollbackErr != nil {
1585
                s.logger.Warn(ctx, "Failed to rollback transaction",
1586
                    map[string]interface{}{
1587
                        "story_id": storyID,
1588
                        "user_id":  userID,
1589
                        "error":    rollbackErr.Error(),
1590
                    })
1591
            }
1592
            span.AddEvent("transaction_rolled_back")
1593
        }
1594
    }()
1595

1596
    // Create the section within the transaction
1597
    section := &models.StorySection{
1598
        StoryID:        storyID,
1599
        SectionNumber:  0, // Will be set by createSectionInTx
1600
        Content:        sectionContent,
1601
        LanguageLevel:  user.CurrentLevel.String,
1602
        WordCount:      wordCount,
1603
        GeneratedBy:    generatorType,
1604
        GeneratedAt:    time.Now(),
1605
        GenerationDate: time.Now().Truncate(24 * time.Hour),
1606
    }
1607

1608
    // Get the next section number within the transaction
1609
    var maxSectionNumber int
1610
    query := "SELECT COALESCE(MAX(section_number), 0) FROM story_sections WHERE story_id = $1"
1611
    err = tx.QueryRowContext(ctx, query, storyID).Scan(&maxSectionNumber)
1612
    if err != nil {
1613
        return nil, contextutils.WrapErrorf(err, "failed to get max section number")
1614
    }
1615
    section.SectionNumber = maxSectionNumber + 1
1616
    span.SetAttributes(attribute.Int("section.number", section.SectionNumber))
1617

1618
    // Create the section in the database within the transaction
1619
    if err := s.createSectionInTx(ctx, tx, section); err != nil {
1620
        return nil, contextutils.WrapErrorf(err, "failed to create story section")
1621
    }
1622
    span.AddEvent("section_created")
1623

1624
    // Generate comprehension questions for the section
1625
    questionsReq := &models.StoryQuestionsRequest{
1626
        UserID:        userID,
1627
        SectionID:     section.ID,
1628
        Language:      story.Language,
1629
        Level:         user.CurrentLevel.String,
1630
        SectionText:   sectionContent,
1631
        QuestionCount: s.config.Story.QuestionsPerSection,
1632
    }
1633

1634
    var questions []*models.StorySectionQuestionData
1635
    questions, err = aiService.GenerateStoryQuestions(ctx, userAIConfig, questionsReq)
1636
    if err != nil {
1637
        // Check if this is a context cancellation error
1638
        if ctx.Err() == context.DeadlineExceeded {
1639
            s.logger.Warn(ctx, "Story questions generation timed out, continuing without questions",
1640
                map[string]interface{}{
1641
                    "section_id": section.ID,
1642
                    "story_id":   storyID,
1643
                    "user_id":    userID,
1644
                    "error":      err.Error(),
1645
                })
1646
        } else {
1647
            s.logger.Warn(ctx, "Failed to generate questions for story section",
1648
                map[string]interface{}{
1649
                    "section_id": section.ID,
1650
                    "story_id":   storyID,
1651
                    "user_id":    userID,
1652
                    "error":      err.Error(),
1653
                })
1654
            span.AddEvent("failed_to_generate_questions")
1655
        }
1656
        // Continue anyway - questions are nice to have but not critical
1657
    } else {
1658
        // Convert to database model slice (dereference pointers)
1659
        dbQuestions := make([]models.StorySectionQuestionData, len(questions))
1660
        for i, q := range questions {
1661
            dbQuestions[i] = *q
1662
        }
1663

1664
        // Save the questions to the database within the same transaction
1665
        if err := s.createSectionQuestionsInTx(ctx, tx, section.ID, dbQuestions); err != nil {
1666
            s.logger.Warn(ctx, "Failed to save story questions",
1667
                map[string]interface{}{
1668
                    "section_id": section.ID,
1669
                    "story_id":   storyID,
1670
                    "user_id":    userID,
1671
                    "error":      err.Error(),
1672
                })
1673
            span.AddEvent("failed_to_save_questions")
1674
        }
1675
        span.AddEvent("questions_saved")
1676
    }
1677

1678
    // Commit the transaction
1679
    if err := tx.Commit(); err != nil {
1680
        span.AddEvent("failed_to_commit_transaction")
1681
        return nil, contextutils.WrapErrorf(err, "failed to commit transaction")
1682
    }
1683
    committed = true
1684
    span.AddEvent("transaction_committed")
1685

1686
    // Update the story's last generation time
1687
    if err := s.UpdateLastGenerationTime(ctx, storyID, generatorType); err != nil {
1688
        s.logger.Warn(ctx, "Failed to update story generation time",
1689
            map[string]interface{}{
1690
                "story_id": storyID,
1691
                "user_id":  userID,
1692
                "error":    err.Error(),
1693
            })
1694
    }
1695

1696
    s.logger.Info(ctx, "Story section generated successfully",
1697
        map[string]interface{}{
1698
            "story_id":       storyID,
1699
            "section_id":     section.ID,
1700
            "section_number": section.SectionNumber,
1701
            "user_id":        userID,
1702
            "word_count":     wordCount,
1703
            "question_count": len(questions),
1704
        })
1705

1706
    // Load questions for the section
1707
    sectionQuestions, err := s.GetSectionQuestions(ctx, section.ID)
1708
    if err != nil {
1709
        s.logger.Warn(ctx, "Failed to load section questions after generation",
1710
            map[string]interface{}{
1711
                "section_id": section.ID,
1712
                "story_id":   storyID,
1713
                "user_id":    userID,
1714
                "error":      err.Error(),
1715
            })
1716
        // Return section without questions rather than failing
1717
        sectionQuestions = []models.StorySectionQuestion{}
1718
    }
1719

1720
    return &models.StorySectionWithQuestions{
1721
        StorySection: *section,
1722
        Questions:    sectionQuestions,
1723
    }, nil
1724
}
1725


			
quizapp internal services worker_service.go
63.9%
Statements
23/36
1
// Package services provides business logic services for the quiz application.
2
package services
3

4
import (
5
    "context"
6
    "database/sql"
7
    "time"
8

9
    "quizapp/internal/config"
10
    "quizapp/internal/models"
11
    "quizapp/internal/observability"
12
    contextutils "quizapp/internal/utils"
13

14
    "go.opentelemetry.io/otel"
15
    "go.opentelemetry.io/otel/attribute"
16
    "go.opentelemetry.io/otel/trace"
17
)
18

19
// TestEmailService implements the Mailer interface for testing purposes
20
// It doesn't actually send emails but logs the operations and records them in the database
21
type TestEmailService struct {
22
    cfg    *config.Config
23
    logger *observability.Logger
24
    db     *sql.DB
25
}
26

27
// NewTestEmailService creates a new TestEmailService instance
28
6x
func NewTestEmailService(cfg *config.Config, logger *observability.Logger) *TestEmailService {
29
6x
    return &TestEmailService{
30
6x
        cfg:    cfg,
31
6x
        logger: logger,
32
6x
    }
33
6x
}
34

35
// NewTestEmailServiceWithDB creates a new TestEmailService instance with database connection
36
1x
func NewTestEmailServiceWithDB(cfg *config.Config, logger *observability.Logger, db *sql.DB) *TestEmailService {
37
1x
    return &TestEmailService{
38
1x
        cfg:    cfg,
39
1x
        logger: logger,
40
1x
        db:     db,
41
1x
    }
42
1x
}
43

44
// SendDailyReminder sends a daily reminder email to a user (test mode - just logs)
45
2x
func (e *TestEmailService) SendDailyReminder(ctx context.Context, user *models.User) error {
46
2x
    ctx, span := otel.Tracer("test-email-service").Start(ctx, "SendDailyReminder",
47
2x
        trace.WithAttributes(
48
2x
            attribute.Int("user.id", user.ID),
49
2x
            attribute.String("user.email", user.Email.String),
50
2x
        ),
51
2x
    )
52
2x
    defer span.End()
53
2x

54
2x
    if !user.Email.Valid || user.Email.String == "" {
55
        e.logger.Warn(ctx, "User has no email address, skipping daily reminder", map[string]interface{}{
56
            "user_id": user.ID,
57
        })
58
        return nil
59
    }
60

61
    // Generate email data (same as real service) - not used in test mode but kept for consistency
62
2x
    _ = map[string]interface{}{
63
2x
        "Username":       user.Username,
64
2x
        "QuizAppURL":     e.cfg.Server.AppBaseURL,
65
2x
        "CurrentDate":    time.Now().Format("January 2, 2006"),
66
2x
        "DailyGoal":      10,
67
2x
        "StreakDays":     5,
68
2x
        "TotalQuestions": 150,
69
2x
        "Level":          "B1",
70
2x
        "Language":       "Italian",
71
2x
    }
72
2x

73
2x
    // Log the email operation instead of sending. Use the same subject as the
74
2x
    // real service to avoid confusion, but do NOT record a second entry in the
75
2x
    // database here â recording is handled by caller to ensure a single source
76
2x
    // of truth for sent notifications.
77
2x
    e.logger.Info(ctx, "TEST MODE: Would send daily reminder email", map[string]interface{}{
78
2x
        "user_id":   user.ID,
79
2x
        "email":     user.Email.String,
80
2x
        "template":  "daily_reminder",
81
2x
        "subject":   "Time for your daily quiz! ð",
82
2x
        "test_mode": true,
83
2x
    })
84
2x

85
2x
    return nil
86
}
87

88
// SendEmail sends a generic email with the given parameters (test mode - just logs)
89
1x
func (e *TestEmailService) SendEmail(ctx context.Context, to, subject, templateName string, data map[string]interface{}) error {
90
1x
    ctx, span := otel.Tracer("test-email-service").Start(ctx, "SendEmail",
91
1x
        trace.WithAttributes(
92
1x
            attribute.String("email.to", to),
93
1x
            attribute.String("email.subject", subject),
94
1x
            attribute.String("email.template", templateName),
95
1x
        ),
96
1x
    )
97
1x
    defer span.End()
98
1x

99
1x
    // Log the email operation instead of sending
100
1x
    e.logger.Info(ctx, "TEST MODE: Would send email", map[string]interface{}{
101
1x
        "to":        to,
102
1x
        "subject":   subject,
103
1x
        "template":  templateName,
104
1x
        "test_mode": true,
105
1x
        "data_keys": getMapKeys(data),
106
1x
    })
107
1x

108
1x
    // Record the notification in the database if we have a DB connection
109
1x
    if e.db != nil {
110
        // For test emails, we don't have a user ID, so we'll use 0
111
        err := e.RecordSentNotification(ctx, 0, "test_email", subject, templateName, "sent", "")
112
        if err != nil {
113
            e.logger.Error(ctx, "Failed to record test notification", err, map[string]interface{}{
114
                "to":       to,
115
                "template": templateName,
116
            })
117
        }
118
    }
119

120
1x
    return nil
121
}
122

123
// RecordSentNotification records a sent notification in the database
124
1x
func (e *TestEmailService) RecordSentNotification(ctx context.Context, userID int, notificationType, subject, templateName, status, errorMessage string) error {
125
1x
    ctx, span := otel.Tracer("test-email-service").Start(ctx, "RecordSentNotification",
126
1x
        trace.WithAttributes(
127
1x
            attribute.Int("user.id", userID),
128
1x
            attribute.String("notification.type", notificationType),
129
1x
            attribute.String("notification.status", status),
130
1x
        ),
131
1x
    )
132
1x
    defer span.End()
133
1x

134
1x
    if e.db == nil {
135
1x
        e.logger.Warn(ctx, "No database connection available for recording notification", map[string]interface{}{
136
1x
            "user_id":           userID,
137
1x
            "notification_type": notificationType,
138
1x
        })
139
1x
        return nil
140
1x
    }
141

142
    query := `
143
        INSERT INTO sent_notifications (user_id, notification_type, subject, template_name, sent_at, status, error_message)
144
        VALUES ($1, $2, $3, $4, $5, $6, $7)
145
    `
146

147
    _, err := e.db.ExecContext(ctx, query, userID, notificationType, subject, templateName, time.Now(), status, errorMessage)
148
    if err != nil {
149
        span.RecordError(err)
150
        e.logger.Error(ctx, "Failed to record sent notification", err, map[string]interface{}{
151
            "user_id":           userID,
152
            "notification_type": notificationType,
153
            "status":            status,
154
        })
155
        return contextutils.WrapError(err, "failed to record sent notification")
156
    }
157

158
    e.logger.Info(ctx, "Recorded sent notification", map[string]interface{}{
159
        "user_id":           userID,
160
        "notification_type": notificationType,
161
        "status":            status,
162
    })
163

164
    return nil
165
}
166

167
// IsEnabled returns whether email functionality is enabled (always true for test service)
168
3x
func (e *TestEmailService) IsEnabled() bool {
169
3x
    return true
170
3x
}
171

172
// getMapKeys returns the keys of a map as a slice of strings
173
1x
func getMapKeys(data map[string]interface{}) []string {
174
1x
    keys := make([]string, 0, len(data))
175
1x
    for k := range data {
176
1x
        keys = append(keys, k)
177
1x
    }
178
1x
    return keys
179
}
180


			
quizapp internal services worker_service.go
66.7%
Statements
26/39
1
//go:build integration
2

3
package services
4

5
import (
6
    "context"
7
    "database/sql"
8
    "os"
9
    "testing"
10

11
    "quizapp/internal/config"
12
    "quizapp/internal/database"
13
    "quizapp/internal/observability"
14

15
    "github.com/stretchr/testify/require"
16
)
17

18
// SharedTestDBSetup provides a clean, isolated database for each integration test
19
// Uses the optimized CleanupTestDatabase function for consistent cleanup
20
190x
func SharedTestDBSetup(t *testing.T) *sql.DB {
21
190x
    observabilityLogger := observability.NewLogger(&config.OpenTelemetryConfig{EnableLogging: false})
22
190x
    dbManager := database.NewManager(observabilityLogger)
23
190x

24
190x
    // Require TEST_DATABASE_URL environment variable to be set
25
190x
    databaseURL := os.Getenv("TEST_DATABASE_URL")
26
190x
    if databaseURL == "" {
27
        t.Fatal("TEST_DATABASE_URL environment variable must be set for integration tests")
28
    }
29

30
190x
    db, err := dbManager.InitDB(databaseURL)
31
190x
    require.NoError(t, err)
32
190x

33
190x
    // Use the optimized cleanup function
34
190x
    CleanupTestDatabase(db, t)
35
190x

36
190x
    return db
37
}
38

39
// cleanupDatabase performs the core database cleanup operations
40
// This is the shared implementation used by both CleanupTestDatabase and SharedTestSuite.Cleanup
41
213x
func cleanupDatabase(db *sql.DB, logger *observability.Logger) {
42
213x
    ctx := context.Background()
43
213x
    tx, err := db.BeginTx(ctx, nil)
44
213x
    if err != nil {
45
        if logger != nil {
46
            logger.Error(ctx, "Failed to begin cleanup transaction", err)
47
        }
48
        return
49
    }
50
213x
    defer func() {
51
213x
        if err != nil {
52
            tx.Rollback()
53
        }
54
    }()
55

56
    // Fast cleanup with batched operations
57
213x
    cleanupQueries := []string{
58
213x
        "TRUNCATE TABLE user_responses CASCADE",
59
213x
        "TRUNCATE TABLE performance_metrics CASCADE",
60
213x
        "TRUNCATE TABLE user_question_metadata CASCADE",
61
213x
        "TRUNCATE TABLE question_priority_scores CASCADE",
62
213x
        "TRUNCATE TABLE user_learning_preferences CASCADE",
63
213x
        "TRUNCATE TABLE user_questions CASCADE",
64
213x
        "TRUNCATE TABLE questions CASCADE",
65
213x
        "TRUNCATE TABLE worker_status CASCADE",
66
213x
        "TRUNCATE TABLE worker_settings CASCADE",
67
213x
        "TRUNCATE TABLE user_api_keys CASCADE",
68
213x
        "TRUNCATE TABLE user_roles CASCADE",
69
213x
        "TRUNCATE TABLE question_reports CASCADE",
70
213x
        "TRUNCATE TABLE notification_errors CASCADE",
71
213x
        "TRUNCATE TABLE upcoming_notifications CASCADE",
72
213x
        "TRUNCATE TABLE sent_notifications CASCADE",
73
213x
        "TRUNCATE TABLE auth_api_keys CASCADE",
74
213x
        "TRUNCATE TABLE daily_question_assignments CASCADE",
75
213x
        "TRUNCATE TABLE story_sections CASCADE",
76
213x
        "TRUNCATE TABLE story_section_questions CASCADE",
77
213x
        "TRUNCATE TABLE stories CASCADE",
78
213x
        "TRUNCATE TABLE snippets CASCADE",
79
213x
        "TRUNCATE TABLE usage_stats CASCADE",
80
213x
        "TRUNCATE TABLE users CASCADE",
81
213x
    }
82
213x

83
213x
    for _, query := range cleanupQueries {
84
4899x
        _, err := tx.ExecContext(ctx, query)
85
4899x
        if err != nil {
86
            if logger != nil {
87
                logger.Warn(ctx, "Could not execute cleanup query", map[string]interface{}{
88
                    "query": query,
89
                })
90
            }
91
        }
92
    }
93

94
    // Reset sequences
95
213x
    sequenceQueries := []string{
96
213x
        "ALTER SEQUENCE users_id_seq RESTART WITH 1",
97
213x
        "ALTER SEQUENCE questions_id_seq RESTART WITH 1",
98
213x
        "ALTER SEQUENCE user_responses_id_seq RESTART WITH 1",
99
213x
        "ALTER SEQUENCE performance_metrics_id_seq RESTART WITH 1",
100
213x
        "ALTER SEQUENCE snippets_id_seq RESTART WITH 1",
101
213x
        "ALTER SEQUENCE auth_api_keys_id_seq RESTART WITH 1",
102
213x
    }
103
213x

104
213x
    for _, query := range sequenceQueries {
105
1278x
        _, err := tx.ExecContext(ctx, query)
106
1278x
        if err != nil {
107
            if logger != nil {
108
                logger.Warn(ctx, "Could not reset sequence", map[string]interface{}{
109
                    "query": query,
110
                })
111
            }
112
        }
113
    }
114

115
    // Re-insert default worker settings
116
213x
    _, err = tx.ExecContext(ctx, `
117
213x
        INSERT INTO worker_settings (setting_key, setting_value, created_at, updated_at)
118
213x
        VALUES ('global_pause', 'false', NOW(), NOW())
119
213x
        ON CONFLICT (setting_key) DO NOTHING;
120
213x
    `)
121
213x
    if err != nil {
122
        if logger != nil {
123
            logger.Error(ctx, "Failed to insert worker settings", err)
124
        }
125
    }
126

127
213x
    err = tx.Commit()
128
213x
    if err != nil {
129
        if logger != nil {
130
            logger.Error(ctx, "Failed to commit cleanup transaction", err)
131
        }
132
    }
133
}
134

135
// CleanupTestDatabase cleans up the database for integration tests
136
// This function can be used by any integration test that needs to clean up the database
137
// Optimized to use batched transactions for better performance
138
213x
func CleanupTestDatabase(db *sql.DB, t *testing.T) {
139
213x
    cleanupDatabase(db, nil)
140
213x
}
141


			
quizapp internal services worker_service.go
50.0%
Statements
32/64
1
package services
2

3
import (
4
    "context"
5
    "crypto/sha256"
6
    "database/sql"
7
    "fmt"
8
    "sync"
9
    "time"
10

11
    "quizapp/internal/models"
12
    "quizapp/internal/observability"
13
    contextutils "quizapp/internal/utils"
14

15
    "go.opentelemetry.io/otel/attribute"
16
)
17

18
// TranslationCacheRepository defines the interface for translation cache operations
19
type TranslationCacheRepository interface {
20
    // GetCachedTranslation retrieves a cached translation if it exists and is not expired
21
    GetCachedTranslation(ctx context.Context, textHash, sourceLang, targetLang string) (*models.TranslationCache, error)
22

23
    // SaveTranslation stores a translation in the cache with a 30-day expiration
24
    SaveTranslation(ctx context.Context, textHash, originalText, sourceLang, targetLang, translatedText string) error
25

26
    // CleanupExpiredTranslations removes expired translation cache entries
27
    CleanupExpiredTranslations(ctx context.Context) (int64, error)
28
}
29

30
// TranslationCacheRepositoryImpl implements TranslationCacheRepository
31
type TranslationCacheRepositoryImpl struct {
32
    db     *sql.DB
33
    logger *observability.Logger
34
}
35

36
// NewTranslationCacheRepository creates a new translation cache repository
37
1x
func NewTranslationCacheRepository(db *sql.DB, logger *observability.Logger) TranslationCacheRepository {
38
1x
    return &TranslationCacheRepositoryImpl{
39
1x
        db:     db,
40
1x
        logger: logger,
41
1x
    }
42
1x
}
43

44
// HashText generates a SHA-256 hash of the input text
45
13x
func HashText(text string) string {
46
13x
    hash := sha256.Sum256([]byte(text))
47
13x
    return fmt.Sprintf("%x", hash)
48
13x
}
49

50
// GetCachedTranslation retrieves a cached translation if it exists and is not expired
51
8x
func (r *TranslationCacheRepositoryImpl) GetCachedTranslation(ctx context.Context, textHash, sourceLang, targetLang string) (result *models.TranslationCache, err error) {
52
8x
    ctx, span := observability.TraceDatabaseFunction(ctx, "get_cached_translation",
53
8x
        attribute.String("cache.text_hash", textHash),
54
8x
        attribute.String("cache.source_language", sourceLang),
55
8x
        attribute.String("cache.target_language", targetLang),
56
8x
    )
57
8x
    defer observability.FinishSpan(span, &err)
58
8x

59
8x
    query := `
60
8x
        SELECT id, text_hash, original_text, source_language, target_language,
61
8x
               translated_text, created_at, expires_at
62
8x
        FROM translation_cache
63
8x
        WHERE text_hash = $1
64
8x
          AND source_language = $2
65
8x
          AND target_language = $3
66
8x
          AND expires_at > NOW()
67
8x
    `
68
8x

69
8x
    cache := &models.TranslationCache{}
70
8x
    err = r.db.QueryRowContext(ctx, query, textHash, sourceLang, targetLang).Scan(
71
8x
        &cache.ID,
72
8x
        &cache.TextHash,
73
8x
        &cache.OriginalText,
74
8x
        &cache.SourceLanguage,
75
8x
        &cache.TargetLanguage,
76
8x
        &cache.TranslatedText,
77
8x
        &cache.CreatedAt,
78
8x
        &cache.ExpiresAt,
79
8x
    )
80
8x

81
8x
    if err == sql.ErrNoRows {
82
2x
        span.SetAttributes(attribute.Bool("cache.found", false))
83
2x
        return nil, nil // Not found or expired
84
2x
    }
85

86
6x
    if err != nil {
87
        err = contextutils.WrapError(err, "failed to query translation cache")
88
        return nil, err
89
    }
90

91
6x
    span.SetAttributes(attribute.Bool("cache.found", true))
92
6x
    return cache, nil
93
}
94

95
// SaveTranslation stores a translation in the cache with a 30-day expiration
96
7x
func (r *TranslationCacheRepositoryImpl) SaveTranslation(ctx context.Context, textHash, originalText, sourceLang, targetLang, translatedText string) (err error) {
97
7x
    ctx, span := observability.TraceDatabaseFunction(ctx, "save_translation_cache",
98
7x
        attribute.String("cache.text_hash", textHash),
99
7x
        attribute.String("cache.source_language", sourceLang),
100
7x
        attribute.String("cache.target_language", targetLang),
101
7x
        attribute.Int("cache.original_text_length", len(originalText)),
102
7x
        attribute.Int("cache.translated_text_length", len(translatedText)),
103
7x
    )
104
7x
    defer observability.FinishSpan(span, &err)
105
7x

106
7x
    expiresAt := time.Now().Add(30 * 24 * time.Hour) // 30 days from now
107
7x

108
7x
    query := `
109
7x
        INSERT INTO translation_cache (text_hash, original_text, source_language, target_language, translated_text, expires_at)
110
7x
        VALUES ($1, $2, $3, $4, $5, $6)
111
7x
        ON CONFLICT (text_hash, source_language, target_language)
112
7x
        DO UPDATE SET
113
7x
            translated_text = EXCLUDED.translated_text,
114
7x
            expires_at = EXCLUDED.expires_at,
115
7x
            created_at = CURRENT_TIMESTAMP
116
7x
    `
117
7x

118
7x
    _, err = r.db.ExecContext(ctx, query, textHash, originalText, sourceLang, targetLang, translatedText, expiresAt)
119
7x
    if err != nil {
120
        err = contextutils.WrapError(err, "failed to save translation to cache")
121
        return err
122
    }
123

124
7x
    span.SetAttributes(
125
7x
        attribute.String("cache.expires_at", expiresAt.Format(time.RFC3339)),
126
7x
    )
127
7x

128
7x
    return nil
129
}
130

131
// CleanupExpiredTranslations removes expired translation cache entries
132
1x
func (r *TranslationCacheRepositoryImpl) CleanupExpiredTranslations(ctx context.Context) (count int64, err error) {
133
1x
    ctx, span := observability.TraceDatabaseFunction(ctx, "cleanup_expired_translations")
134
1x
    defer observability.FinishSpan(span, &err)
135
1x

136
1x
    query := `DELETE FROM translation_cache WHERE expires_at < NOW()`
137
1x

138
1x
    result, err := r.db.ExecContext(ctx, query)
139
1x
    if err != nil {
140
        err = contextutils.WrapError(err, "failed to cleanup expired translations")
141
        return 0, err
142
    }
143

144
1x
    rowsAffected, err := result.RowsAffected()
145
1x
    if err != nil {
146
        err = contextutils.WrapError(err, "failed to get rows affected")
147
        return 0, err
148
    }
149

150
1x
    span.SetAttributes(attribute.Int64("cache.deleted_count", rowsAffected))
151
1x
    r.logger.Info(ctx, "Cleaned up expired translation cache entries", map[string]interface{}{
152
1x
        "deleted_count": rowsAffected,
153
1x
    })
154
1x

155
1x
    return rowsAffected, nil
156
}
157

158
// InMemoryTranslationCacheRepository is an in-memory implementation for testing
159
type InMemoryTranslationCacheRepository struct {
160
    cache map[string]*models.TranslationCache
161
    mu    sync.RWMutex
162
}
163

164
// NewInMemoryTranslationCacheRepository creates a new in-memory translation cache repository
165
func NewInMemoryTranslationCacheRepository() *InMemoryTranslationCacheRepository {
166
    return &InMemoryTranslationCacheRepository{
167
        cache: make(map[string]*models.TranslationCache),
168
    }
169
}
170

171
// GetCachedTranslation retrieves a cached translation from the in-memory cache
172
func (m *InMemoryTranslationCacheRepository) GetCachedTranslation(_ context.Context, textHash, sourceLang, targetLang string) (*models.TranslationCache, error) {
173
    m.mu.RLock()
174
    defer m.mu.RUnlock()
175

176
    key := textHash + "|" + sourceLang + "|" + targetLang
177
    cached, exists := m.cache[key]
178
    if !exists {
179
        return nil, nil
180
    }
181

182
    // Check if expired
183
    if time.Now().After(cached.ExpiresAt) {
184
        return nil, nil
185
    }
186

187
    return cached, nil
188
}
189

190
// SaveTranslation saves a translation to the in-memory cache
191
func (m *InMemoryTranslationCacheRepository) SaveTranslation(_ context.Context, textHash, originalText, sourceLang, targetLang, translatedText string) error {
192
    m.mu.Lock()
193
    defer m.mu.Unlock()
194

195
    key := textHash + "|" + sourceLang + "|" + targetLang
196
    m.cache[key] = &models.TranslationCache{
197
        TextHash:       textHash,
198
        OriginalText:   originalText,
199
        SourceLanguage: sourceLang,
200
        TargetLanguage: targetLang,
201
        TranslatedText: translatedText,
202
        CreatedAt:      time.Now(),
203
        ExpiresAt:      time.Now().Add(30 * 24 * time.Hour),
204
    }
205

206
    return nil
207
}
208

209
// CleanupExpiredTranslations removes expired entries from the in-memory cache
210
func (m *InMemoryTranslationCacheRepository) CleanupExpiredTranslations(_ context.Context) (int64, error) {
211
    m.mu.Lock()
212
    defer m.mu.Unlock()
213

214
    now := time.Now()
215
    deleted := int64(0)
216

217
    for key, cached := range m.cache {
218
        if now.After(cached.ExpiresAt) {
219
            delete(m.cache, key)
220
            deleted++
221
        }
222
    }
223

224
    return deleted, nil
225
}
226


			
quizapp internal services worker_service.go
22.1%
Statements
23/104
1
package services
2

3
import (
4
    "bytes"
5
    "context"
6
    "encoding/json"
7
    "fmt"
8
    "io"
9
    "net/http"
10
    "strings"
11
    "time"
12

13
    "quizapp/internal/config"
14
    "quizapp/internal/observability"
15
    "quizapp/internal/serviceinterfaces"
16
    contextutils "quizapp/internal/utils"
17

18
    "go.opentelemetry.io/otel/attribute"
19
)
20

21
// TranslationServiceInterface defines the interface for translation services
22
type TranslationServiceInterface = serviceinterfaces.TranslationService
23

24
// GoogleTranslationService handles translation requests using Google Translate API
25
type GoogleTranslationService struct {
26
    config        *config.Config
27
    httpClient    *http.Client
28
    usageStatsSvc UsageStatsServiceInterface
29
    cacheRepo     TranslationCacheRepository
30
    logger        *observability.Logger
31
}
32

33
// NewGoogleTranslationService creates a new Google translation service instance
34
3x
func NewGoogleTranslationService(config *config.Config, usageStatsSvc UsageStatsServiceInterface, cacheRepo TranslationCacheRepository, logger *observability.Logger) *GoogleTranslationService {
35
3x
    return &GoogleTranslationService{
36
3x
        config: config,
37
3x
        httpClient: &http.Client{
38
3x
            Timeout: 30 * time.Second,
39
3x
        },
40
3x
        usageStatsSvc: usageStatsSvc,
41
3x
        cacheRepo:     cacheRepo,
42
3x
        logger:        logger,
43
3x
    }
44
3x
}
45

46
// GoogleTranslateRequest represents the request format for Google Translate API
47
type GoogleTranslateRequest struct {
48
    Q      []string `json:"q"`
49
    Target string   `json:"target"`
50
    Source string   `json:"source,omitempty"`
51
    Format string   `json:"format"`
52
}
53

54
// normalizeLanguageCode converts language names to ISO codes for Google Translate API
55
func normalizeLanguageCode(lang string, languageLevels map[string]config.LanguageLevelConfig) string {
56
    // Check if it's a language name in our config
57
    for languageName, levelConfig := range languageLevels {
58
        if strings.EqualFold(languageName, lang) {
59
            return levelConfig.Code
60
        }
61
    }
62

63
    // If it's already a valid ISO code or unknown, return as-is
64
    return lang
65
}
66

67
// GoogleTranslateResponse represents the response format from Google Translate API
68
type GoogleTranslateResponse struct {
69
    Data struct {
70
        Translations []struct {
71
            TranslatedText         string `json:"translatedText"`
72
            DetectedSourceLanguage string `json:"detectedSourceLanguage"`
73
        } `json:"translations"`
74
    } `json:"data"`
75
}
76

77
// Translate translates text using the configured translation provider
78
func (s *GoogleTranslationService) Translate(ctx context.Context, req serviceinterfaces.TranslateRequest) (result *serviceinterfaces.TranslateResponse, err error) {
79
    ctx, span := observability.TraceTranslationFunction(ctx, "translate",
80
        attribute.String("translation.target_language", req.TargetLanguage),
81
        attribute.String("translation.source_language", req.SourceLanguage),
82
        attribute.Int("translation.text_length", len(req.Text)),
83
    )
84
    defer observability.FinishSpan(span, &err)
85

86
    if !s.config.Translation.Enabled {
87
        return nil, contextutils.NewAppError(contextutils.ErrorCodeServiceUnavailable, contextutils.SeverityError, "Translation service is disabled", "")
88
    }
89

90
    // Get provider config for usage stats and quota checking
91
    providerConfig, exists := s.config.Translation.Providers[s.config.Translation.DefaultProvider]
92
    if !exists {
93
        err = contextutils.NewAppError(contextutils.ErrorCodeServiceUnavailable, contextutils.SeverityError, "Translation provider not configured", "")
94
        return nil, err
95
    }
96

97
    span.SetAttributes(attribute.String("translation.provider", providerConfig.Code))
98

99
    // Generate hash for cache lookup
100
    textHash := HashText(req.Text)
101
    span.SetAttributes(attribute.String("cache.text_hash", textHash))
102

103
    // Normalize source language for consistent cache lookup
104
    normalizedSourceLang := normalizeLanguageCode(req.SourceLanguage, s.config.LanguageLevels)
105
    normalizedTargetLang := normalizeLanguageCode(req.TargetLanguage, s.config.LanguageLevels)
106

107
    // Check cache first (provider-agnostic)
108
    cachedTranslation, err := s.cacheRepo.GetCachedTranslation(ctx, textHash, normalizedSourceLang, normalizedTargetLang)
109
    if err != nil {
110
        // Log cache error but don't fail the translation request
111
        s.logger.Error(ctx, "Failed to check translation cache", err, map[string]interface{}{
112
            "text_hash":       textHash,
113
            "source_language": normalizedSourceLang,
114
            "target_language": normalizedTargetLang,
115
        })
116
    } else if cachedTranslation != nil {
117
        // Cache hit - return cached translation
118
        span.SetAttributes(
119
            attribute.Bool("cache.hit", true),
120
            attribute.String("cache.created_at", cachedTranslation.CreatedAt.Format(time.RFC3339)),
121
        )
122

123
        // Record cache hit in usage stats
124
        if err := s.usageStatsSvc.RecordUsage(ctx, providerConfig.Code, "translation_cache_hit", len(req.Text), 1); err != nil {
125
            s.logger.Error(ctx, "Failed to record translation cache hit", err)
126
        }
127

128
        return &serviceinterfaces.TranslateResponse{
129
            TranslatedText: cachedTranslation.TranslatedText,
130
            SourceLanguage: cachedTranslation.SourceLanguage,
131
            TargetLanguage: cachedTranslation.TargetLanguage,
132
        }, nil
133
    }
134

135
    // Cache miss - proceed with API call
136
    span.SetAttributes(attribute.Bool("cache.hit", false))
137

138
    // Record cache miss in usage stats
139
    if err := s.usageStatsSvc.RecordUsage(ctx, providerConfig.Code, "translation_cache_miss", 0, 1); err != nil {
140
        s.logger.Error(ctx, "Failed to record translation cache miss", err)
141
    }
142

143
    // Check quota before making the request
144
    if err := s.usageStatsSvc.CheckQuota(ctx, providerConfig.Code, "translation", len(req.Text)); err != nil {
145
        return nil, err
146
    }
147

148
    if providerConfig.APIKey == "" {
149
        err = contextutils.NewAppError(contextutils.ErrorCodeServiceUnavailable, contextutils.SeverityError, "Google Translate API key not configured", "")
150
        return nil, err
151
    }
152

153
    if req.SourceLanguage == "" || req.TargetLanguage == "" {
154
        err = contextutils.NewAppError(contextutils.ErrorCodeInvalidInput, contextutils.SeverityError, "Source and target language are required", "")
155
        return nil, err
156
    }
157

158
    if len(req.Text) == 0 {
159
        err = contextutils.NewAppError(contextutils.ErrorCodeInvalidInput, contextutils.SeverityError, "Text cannot be empty", "")
160
        return nil, err
161
    }
162

163
    if len(req.Text) > providerConfig.MaxTextLength {
164
        err = contextutils.NewAppError(contextutils.ErrorCodeInvalidInput, contextutils.SeverityError, fmt.Sprintf("Text cannot exceed %d characters", providerConfig.MaxTextLength), "")
165
        return nil, err
166
    }
167

168
    // Prepare request - use normalized language codes for Google Translate API
169
    requestBody := GoogleTranslateRequest{
170
        Q:      []string{req.Text},
171
        Target: normalizedTargetLang,
172
        Source: normalizedSourceLang,
173
        Format: "text",
174
    }
175

176
    jsonBody, err := json.Marshal(requestBody)
177
    if err != nil {
178
        err = contextutils.WrapError(err, "failed to marshal request")
179
        return nil, err
180
    }
181

182
    // Build URL
183
    url := fmt.Sprintf("%s%s?key=%s", providerConfig.BaseURL, providerConfig.APIEndpoint, providerConfig.APIKey)
184

185
    // Make request
186
    httpReq, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer(jsonBody))
187
    if err != nil {
188
        err = contextutils.WrapError(err, "failed to create request")
189
        return nil, err
190
    }
191

192
    httpReq.Header.Set("Content-Type", "application/json")
193

194
    resp, err := s.httpClient.Do(httpReq.WithContext(ctx))
195
    if err != nil {
196
        err = contextutils.WrapError(err, "translation request failed")
197
        return nil, err
198
    }
199
    defer func() { _ = resp.Body.Close() }()
200

201
    if resp.StatusCode != http.StatusOK {
202
        body, _ := io.ReadAll(resp.Body)
203
        err = contextutils.NewAppError(contextutils.ErrorCodeServiceUnavailable, contextutils.SeverityError,
204
            fmt.Sprintf("Google Translate API error: %d - %s", resp.StatusCode, string(body)), "")
205
        return nil, err
206
    }
207

208
    // Parse response
209
    var googleResp GoogleTranslateResponse
210
    if err := json.NewDecoder(resp.Body).Decode(&googleResp); err != nil {
211
        err = contextutils.WrapError(err, "failed to decode response")
212
        return nil, err
213
    }
214

215
    if len(googleResp.Data.Translations) == 0 {
216
        err = contextutils.NewAppError(contextutils.ErrorCodeServiceUnavailable, contextutils.SeverityError, "No translation returned from Google Translate API", "")
217
        return nil, err
218
    }
219

220
    translation := googleResp.Data.Translations[0]
221

222
    result = &serviceinterfaces.TranslateResponse{
223
        TranslatedText: translation.TranslatedText,
224
        SourceLanguage: normalizedSourceLang,
225
        TargetLanguage: normalizedTargetLang,
226
    }
227

228
    // Record usage after successful translation
229
    if err := s.usageStatsSvc.RecordUsage(ctx, providerConfig.Code, "translation", len(req.Text), 1); err != nil {
230
        // Log the error but don't fail the translation request
231
        // The translation was successful, we just couldn't record the usage
232
        // This is a non-critical error that should be logged for monitoring
233
        s.logger.Error(ctx, "Failed to record translation usage", err, map[string]interface{}{
234
            "service":    providerConfig.Code,
235
            "usage_type": "translation",
236
            "characters": len(req.Text),
237
            "requests":   1,
238
        })
239
    }
240

241
    // Save translation to cache using the normalized source language
242
    if err := s.cacheRepo.SaveTranslation(ctx, textHash, req.Text, result.SourceLanguage, req.TargetLanguage, result.TranslatedText); err != nil {
243
        // Log the error but don't fail the translation request
244
        span.SetAttributes(attribute.Bool("cache.save_error", true))
245
        s.logger.Error(ctx, "Failed to save translation to cache", err, map[string]interface{}{
246
            "text_hash":       textHash,
247
            "source_language": result.SourceLanguage,
248
            "target_language": req.TargetLanguage,
249
        })
250
    } else {
251
        span.SetAttributes(attribute.Bool("cache.saved", true))
252
    }
253

254
    return result, nil
255
}
256

257
// ValidateLanguageCode validates that a language code is properly formatted
258
10x
func (s *GoogleTranslationService) ValidateLanguageCode(langCode string) error {
259
10x
    if len(langCode) < 2 || len(langCode) > 10 {
260
4x
        return contextutils.NewAppError(contextutils.ErrorCodeInvalidInput, contextutils.SeverityError, "Language code must be 2-10 characters", "")
261
4x
    }
262

263
    // Basic validation - should be alphanumeric with possible hyphens
264
6x
    for _, char := range langCode {
265
18x
        if (char < 'a' || char > 'z') && (char < 'A' || char > 'Z') && (char < '0' || char > '9') && char != '-' {
266
            return contextutils.NewAppError(contextutils.ErrorCodeInvalidInput, contextutils.SeverityError, "Invalid language code format", "")
267
        }
268
    }
269

270
6x
    return nil
271
}
272

273
// GetSupportedLanguages returns a list of supported target languages for translation
274
1x
func (s *GoogleTranslationService) GetSupportedLanguages() []string {
275
1x
    // Common languages supported by Google Translate API
276
1x
    return []string{
277
1x
        "af", "sq", "am", "ar", "hy", "az", "eu", "be", "bn", "bs", "bg", "ca", "ceb", "ny", "zh", "zh-CN", "zh-TW",
278
1x
        "co", "hr", "cs", "da", "nl", "en", "eo", "et", "tl", "fi", "fr", "fy", "gl", "ka", "de", "el", "gu", "ht",
279
1x
        "ha", "haw", "iw", "hi", "hmn", "hu", "is", "ig", "id", "ga", "it", "ja", "jw", "kn", "kk", "km", "ko", "ku",
280
1x
        "ky", "lo", "la", "lv", "lt", "lb", "mk", "mg", "ms", "ml", "mt", "mi", "mr", "mn", "my", "ne", "no", "ps",
281
1x
        "fa", "pl", "pt", "pa", "ro", "ru", "sm", "gd", "sr", "st", "sn", "sd", "si", "sk", "sl", "so", "es", "su",
282
1x
        "sw", "sv", "tg", "ta", "te", "th", "tr", "uk", "ur", "uz", "vi", "cy", "xh", "yi", "yo", "zu",
283
1x
    }
284
1x
}
285

286
// NoopTranslationService is a no-operation implementation for testing and development
287
type NoopTranslationService struct{}
288

289
// NewNoopTranslationService creates a new noop translation service instance
290
6x
func NewNoopTranslationService() *NoopTranslationService {
291
6x
    return &NoopTranslationService{}
292
6x
}
293

294
// Translate returns the original text unchanged (no-op)
295
2x
func (s *NoopTranslationService) Translate(_ context.Context, req serviceinterfaces.TranslateRequest) (*serviceinterfaces.TranslateResponse, error) {
296
2x
    return &serviceinterfaces.TranslateResponse{
297
2x
        TranslatedText: req.Text,
298
2x
        SourceLanguage: req.SourceLanguage,
299
2x
        TargetLanguage: req.TargetLanguage,
300
2x
        Confidence:     1.0,
301
2x
    }, nil
302
2x
}
303

304
// ValidateLanguageCode validates that a language code is properly formatted
305
10x
func (s *NoopTranslationService) ValidateLanguageCode(langCode string) error {
306
10x
    if len(langCode) < 2 || len(langCode) > 10 {
307
4x
        return contextutils.NewAppError(contextutils.ErrorCodeInvalidInput, contextutils.SeverityError, "Language code must be 2-10 characters", "")
308
4x
    }
309

310
    // Basic validation - should be alphanumeric with possible hyphens
311
6x
    for _, char := range langCode {
312
18x
        if (char < 'a' || char > 'z') && (char < 'A' || char > 'Z') && (char < '0' || char > '9') && char != '-' {
313
            return contextutils.NewAppError(contextutils.ErrorCodeInvalidInput, contextutils.SeverityError, "Invalid language code format", "")
314
        }
315
    }
316

317
6x
    return nil
318
}
319

320
// GetSupportedLanguages returns a list of supported target languages for translation
321
1x
func (s *NoopTranslationService) GetSupportedLanguages() []string {
322
1x
    // Return a subset of common languages for testing
323
1x
    return []string{
324
1x
        "en", "es", "fr", "de", "it", "pt", "ru", "ja", "ko", "zh",
325
1x
    }
326
1x
}
327

328
// NewTranslationService creates a translation service based on configuration
329
// For testing environments, it returns a noop service if translation is disabled
330
// For production, it returns a Google translation service if properly configured
331
4x
func NewTranslationService(config *config.Config, usageStatsSvc UsageStatsServiceInterface, cacheRepo TranslationCacheRepository, logger *observability.Logger) TranslationServiceInterface {
332
4x
    if !config.Translation.Enabled {
333
1x
        return NewNoopTranslationService()
334
1x
    }
335

336
3x
    providerConfig, exists := config.Translation.Providers[config.Translation.DefaultProvider]
337
3x
    if !exists {
338
1x
        // Fallback to noop if provider not configured
339
1x
        return NewNoopTranslationService()
340
1x
    }
341

342
2x
    switch providerConfig.Code {
343
1x
    case "google":
344
1x
        return NewGoogleTranslationService(config, usageStatsSvc, cacheRepo, logger)
345
1x
    default:
346
1x
        // Fallback to noop for unsupported providers
347
1x
        return NewNoopTranslationService()
348
    }
349
}
350


			
quizapp internal services worker_service.go
18.5%
Statements
33/178
1
package services
2

3
import (
4
    "context"
5
    "database/sql"
6
    "fmt"
7
    "time"
8

9
    "quizapp/internal/config"
10
    "quizapp/internal/observability"
11
    contextutils "quizapp/internal/utils"
12

13
    openapi_types "github.com/oapi-codegen/runtime/types"
14
    "go.opentelemetry.io/otel/attribute"
15
)
16

17
// UsageStatsServiceInterface defines the interface for usage statistics tracking
18
type UsageStatsServiceInterface interface {
19
    // CheckQuota checks if a translation request would exceed the monthly quota
20
    CheckQuota(ctx context.Context, serviceName, usageType string, characters int) error
21
    // RecordUsage records the usage of a translation service
22
    RecordUsage(ctx context.Context, serviceName, usageType string, characters, requests int) error
23
    // GetCurrentMonthUsage returns the current month's usage for a service and type
24
    GetCurrentMonthUsage(ctx context.Context, serviceName, usageType string) (*UsageStats, error)
25
    // GetMonthlyQuota returns the monthly quota for a service
26
    GetMonthlyQuota(serviceName string) int64
27
    // GetAllUsageStats returns all usage statistics (for admin interface)
28
    GetAllUsageStats(ctx context.Context) ([]*UsageStats, error)
29
    // GetUsageStatsByService returns usage statistics for a specific service
30
    GetUsageStatsByService(ctx context.Context, serviceName string) ([]*UsageStats, error)
31
    // GetUsageStatsByMonth returns usage statistics for a specific month
32
    GetUsageStatsByMonth(ctx context.Context, year, month int) ([]*UsageStats, error)
33

34
    // AI Token usage tracking for users
35
    // RecordUserAITokenUsage records AI token usage for a specific user
36
    RecordUserAITokenUsage(ctx context.Context, userID int, apiKeyID *int, provider, model, usageType string, promptTokens, completionTokens, totalTokens, requests int) error
37
    // GetUserAITokenUsageStats returns AI token usage statistics for a specific user
38
    GetUserAITokenUsageStats(ctx context.Context, userID int, startDate, endDate time.Time) ([]*UserUsageStats, error)
39
    // GetUserAITokenUsageStatsByDay returns daily aggregated AI token usage for a user
40
    GetUserAITokenUsageStatsByDay(ctx context.Context, userID int, startDate, endDate time.Time) ([]*UserUsageStatsDaily, error)
41
    // GetUserAITokenUsageStatsByHour returns hourly aggregated AI token usage for a user on a specific day
42
    GetUserAITokenUsageStatsByHour(ctx context.Context, userID int, date time.Time) ([]*UserUsageStatsHourly, error)
43
}
44

45
// UsageStats represents usage statistics for a service in a given month
46
type UsageStats struct {
47
    ID             int       `json:"id"`
48
    ServiceName    string    `json:"service_name"`
49
    UsageType      string    `json:"usage_type"`
50
    UsageMonth     time.Time `json:"usage_month"`
51
    CharactersUsed int       `json:"characters_used"`
52
    RequestsMade   int       `json:"requests_made"`
53
    CreatedAt      time.Time `json:"created_at"`
54
    UpdatedAt      time.Time `json:"updated_at"`
55
}
56

57
// UserUsageStats represents detailed usage statistics for a user
58
type UserUsageStats struct {
59
    ID               int       `json:"id"`
60
    UserID           int       `json:"user_id"`
61
    APIKeyID         *int      `json:"api_key_id,omitempty"`
62
    UsageDate        time.Time `json:"usage_date"`
63
    UsageHour        int       `json:"usage_hour"`
64
    ServiceName      string    `json:"service_name"`
65
    Provider         string    `json:"provider"`
66
    Model            string    `json:"model"`
67
    UsageType        string    `json:"usage_type"`
68
    PromptTokens     int       `json:"prompt_tokens"`
69
    CompletionTokens int       `json:"completion_tokens"`
70
    TotalTokens      int       `json:"total_tokens"`
71
    RequestsMade     int       `json:"requests_made"`
72
    CreatedAt        time.Time `json:"created_at"`
73
    UpdatedAt        time.Time `json:"updated_at"`
74
}
75

76
// UserUsageStatsDaily represents daily aggregated usage for a user
77
type UserUsageStatsDaily struct {
78
    UsageDate             openapi_types.Date `json:"usage_date"`
79
    ServiceName           string             `json:"service_name"`
80
    Provider              string             `json:"provider"`
81
    Model                 string             `json:"model"`
82
    UsageType             string             `json:"usage_type"`
83
    TotalPromptTokens     int                `json:"total_prompt_tokens"`
84
    TotalCompletionTokens int                `json:"total_completion_tokens"`
85
    TotalTokens           int                `json:"total_tokens"`
86
    TotalRequests         int                `json:"total_requests"`
87
}
88

89
// UserUsageStatsHourly represents hourly usage for a user on a specific day
90
type UserUsageStatsHourly struct {
91
    UsageHour             int    `json:"usage_hour"`
92
    ServiceName           string `json:"service_name"`
93
    Provider              string `json:"provider"`
94
    Model                 string `json:"model"`
95
    UsageType             string `json:"usage_type"`
96
    TotalPromptTokens     int    `json:"total_prompt_tokens"`
97
    TotalCompletionTokens int    `json:"total_completion_tokens"`
98
    TotalTokens           int    `json:"total_tokens"`
99
    TotalRequests         int    `json:"total_requests"`
100
}
101

102
// UsageStatsService handles usage statistics tracking and quota management
103
type UsageStatsService struct {
104
    config *config.Config
105
    db     *sql.DB
106
    logger *observability.Logger
107
}
108

109
// NewUsageStatsService creates a new usage stats service
110
2x
func NewUsageStatsService(config *config.Config, db *sql.DB, logger *observability.Logger) *UsageStatsService {
111
2x
    return &UsageStatsService{
112
2x
        config: config,
113
2x
        db:     db,
114
2x
        logger: logger,
115
2x
    }
116
2x
}
117

118
// CheckQuota checks if a translation request would exceed the monthly quota
119
3x
func (s *UsageStatsService) CheckQuota(ctx context.Context, serviceName, usageType string, characters int) (err error) {
120
3x
    ctx, span := observability.TraceUsageStatsFunction(ctx, "check_quota",
121
3x
        attribute.String("service_name", serviceName),
122
3x
        attribute.String("usage_type", usageType),
123
3x
        attribute.Int("characters", characters),
124
3x
    )
125
3x
    defer observability.FinishSpan(span, &err)
126
3x

127
3x
    if !s.config.Translation.Quota.Enabled {
128
        return nil // Quota checking disabled
129
    }
130

131
3x
    currentUsage, err := s.GetCurrentMonthUsage(ctx, serviceName, usageType)
132
3x
    if err != nil {
133
        return contextutils.WrapError(err, "failed to get current usage")
134
    }
135

136
3x
    quota := s.GetMonthlyQuota(serviceName)
137
3x
    newTotal := currentUsage.CharactersUsed + characters
138
3x

139
3x
    if newTotal > int(quota) {
140
1x
        return contextutils.NewAppError(
141
1x
            contextutils.ErrorCodeQuotaExceeded,
142
1x
            contextutils.SeverityWarn,
143
1x
            fmt.Sprintf("Monthly quota exceeded for %s %s service. Used: %d/%d characters",
144
1x
                serviceName, usageType, newTotal, quota),
145
1x
            "",
146
1x
        )
147
1x
    }
148

149
2x
    return nil
150
}
151

152
// RecordUsage records the usage of a translation service
153
3x
func (s *UsageStatsService) RecordUsage(ctx context.Context, serviceName, usageType string, characters, requests int) (err error) {
154
3x
    ctx, span := observability.TraceUsageStatsFunction(ctx, "record_usage",
155
3x
        attribute.String("service_name", serviceName),
156
3x
        attribute.String("usage_type", usageType),
157
3x
        attribute.Int("characters", characters),
158
3x
        attribute.Int("requests", requests),
159
3x
    )
160
3x
    defer observability.FinishSpan(span, &err)
161
3x

162
3x
    currentMonth := time.Now().UTC().Truncate(24*time.Hour).AddDate(0, 0, -time.Now().UTC().Day()+1) // First day of current month
163
3x

164
3x
    query := `
165
3x
        INSERT INTO usage_stats (service_name, usage_type, usage_month, characters_used, requests_made, updated_at)
166
3x
        VALUES ($1, $2, $3, $4, $5, NOW())
167
3x
        ON CONFLICT (service_name, usage_type, usage_month)
168
3x
        DO UPDATE SET
169
3x
            characters_used = usage_stats.characters_used + $4,
170
3x
            requests_made = usage_stats.requests_made + $5,
171
3x
            updated_at = NOW()`
172
3x

173
3x
    _, err = s.db.ExecContext(ctx, query, serviceName, usageType, currentMonth, characters, requests)
174
3x
    if err != nil {
175
        return contextutils.WrapError(err, "failed to record usage")
176
    }
177

178
3x
    return nil
179
}
180

181
// RecordUserAITokenUsage records AI token usage for a specific user
182
func (s *UsageStatsService) RecordUserAITokenUsage(ctx context.Context, userID int, apiKeyID *int, provider, model, usageType string, promptTokens, completionTokens, totalTokens, requests int) (err error) {
183
    ctx, span := observability.TraceUsageStatsFunction(ctx, "record_user_ai_token_usage",
184
        attribute.Int("user_id", userID),
185
        attribute.String("provider", provider),
186
        attribute.String("model", model),
187
        attribute.String("usage_type", usageType),
188
        attribute.Int("prompt_tokens", promptTokens),
189
        attribute.Int("completion_tokens", completionTokens),
190
        attribute.Int("total_tokens", totalTokens),
191
        attribute.Int("requests", requests),
192
    )
193
    defer observability.FinishSpan(span, &err)
194

195
    now := time.Now()
196
    usageDate := now.Truncate(24 * time.Hour) // Start of day
197
    usageHour := now.Hour()
198

199
    query := `
200
        INSERT INTO user_usage_stats (user_id, api_key_id, usage_date, usage_hour, service_name, provider, model, usage_type, prompt_tokens, completion_tokens, total_tokens, requests_made, updated_at)
201
        VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, NOW())
202
        ON CONFLICT (user_id, api_key_id, usage_date, usage_hour, service_name, provider, model, usage_type)
203
        DO UPDATE SET
204
            prompt_tokens = user_usage_stats.prompt_tokens + $9,
205
            completion_tokens = user_usage_stats.completion_tokens + $10,
206
            total_tokens = user_usage_stats.total_tokens + $11,
207
            requests_made = user_usage_stats.requests_made + $12,
208
            updated_at = NOW()`
209

210
    _, err = s.db.ExecContext(ctx, query, userID, apiKeyID, usageDate, usageHour, "ai", provider, model, usageType, promptTokens, completionTokens, totalTokens, requests)
211
    if err != nil {
212
        return contextutils.WrapError(err, "failed to record user ai token usage")
213
    }
214

215
    return nil
216
}
217

218
// GetCurrentMonthUsage returns the current month's usage for a service and type
219
5x
func (s *UsageStatsService) GetCurrentMonthUsage(ctx context.Context, serviceName, usageType string) (stats *UsageStats, err error) {
220
5x
    ctx, span := observability.TraceUsageStatsFunction(ctx, "get_current_month_usage",
221
5x
        attribute.String("service_name", serviceName),
222
5x
        attribute.String("usage_type", usageType),
223
5x
    )
224
5x
    defer observability.FinishSpan(span, &err)
225
5x

226
5x
    currentMonth := time.Now().UTC().Truncate(24*time.Hour).AddDate(0, 0, -time.Now().UTC().Day()+1) // First day of current month
227
5x

228
5x
    query := `
229
5x
        SELECT id, service_name, usage_type, usage_month, characters_used, requests_made, created_at, updated_at
230
5x
        FROM usage_stats
231
5x
        WHERE service_name = $1 AND usage_type = $2 AND usage_month = $3`
232
5x

233
5x
    stats = &UsageStats{}
234
5x
    err = s.db.QueryRowContext(ctx, query, serviceName, usageType, currentMonth).Scan(
235
5x
        &stats.ID, &stats.ServiceName, &stats.UsageType, &stats.UsageMonth,
236
5x
        &stats.CharactersUsed, &stats.RequestsMade, &stats.CreatedAt, &stats.UpdatedAt,
237
5x
    )
238
5x
    if err != nil {
239
2x
        if err == sql.ErrNoRows {
240
2x
            // Return empty stats for new service/month
241
2x
            return &UsageStats{
242
2x
                ServiceName:    serviceName,
243
2x
                UsageType:      usageType,
244
2x
                UsageMonth:     currentMonth,
245
2x
                CharactersUsed: 0,
246
2x
                RequestsMade:   0,
247
2x
            }, nil
248
2x
        }
249
        return nil, contextutils.WrapError(err, "failed to get usage stats")
250
    }
251

252
3x
    return stats, nil
253
}
254

255
// GetMonthlyQuota returns the monthly quota for a service
256
3x
func (s *UsageStatsService) GetMonthlyQuota(serviceName string) int64 {
257
3x
    if !s.config.Translation.Quota.Enabled {
258
        return 0 // No quota limit when disabled
259
    }
260

261
3x
    switch serviceName {
262
2x
    case "google":
263
2x
        return s.config.Translation.Quota.GoogleMonthlyQuota
264
1x
    default:
265
1x
        return s.config.Translation.Quota.DefaultMonthlyQuota
266
    }
267
}
268

269
// GetAllUsageStats returns all usage statistics (for admin interface)
270
func (s *UsageStatsService) GetAllUsageStats(ctx context.Context) (stats []*UsageStats, err error) {
271
    ctx, span := observability.TraceUsageStatsFunction(ctx, "get_all_usage_stats")
272
    defer observability.FinishSpan(span, &err)
273

274
    query := `
275
        SELECT id, service_name, usage_type, usage_month, characters_used, requests_made, created_at, updated_at
276
        FROM usage_stats
277
        ORDER BY usage_month DESC, service_name, usage_type`
278

279
    rows, err := s.db.QueryContext(ctx, query)
280
    if err != nil {
281
        return nil, contextutils.WrapError(err, "failed to query usage stats")
282
    }
283
    defer func() {
284
        if closeErr := rows.Close(); closeErr != nil {
285
            s.logger.Error(ctx, "Failed to close rows", closeErr, map[string]interface{}{})
286
        }
287
    }()
288

289
    stats = []*UsageStats{}
290
    for rows.Next() {
291
        var stat UsageStats
292
        err := rows.Scan(
293
            &stat.ID, &stat.ServiceName, &stat.UsageType, &stat.UsageMonth,
294
            &stat.CharactersUsed, &stat.RequestsMade, &stat.CreatedAt, &stat.UpdatedAt,
295
        )
296
        if err != nil {
297
            return nil, contextutils.WrapError(err, "failed to scan usage stats")
298
        }
299
        stats = append(stats, &stat)
300
    }
301

302
    if err := rows.Err(); err != nil {
303
        return nil, contextutils.WrapError(err, "error iterating usage stats")
304
    }
305

306
    return stats, nil
307
}
308

309
// GetUsageStatsByService returns usage statistics for a specific service
310
func (s *UsageStatsService) GetUsageStatsByService(ctx context.Context, serviceName string) (stats []*UsageStats, err error) {
311
    ctx, span := observability.TraceUsageStatsFunction(ctx, "get_usage_stats_by_service",
312
        attribute.String("service_name", serviceName),
313
    )
314
    defer observability.FinishSpan(span, &err)
315

316
    query := `
317
        SELECT id, service_name, usage_type, usage_month, characters_used, requests_made, created_at, updated_at
318
        FROM usage_stats
319
        WHERE service_name = $1
320
        ORDER BY usage_month DESC, usage_type`
321

322
    rows, err := s.db.QueryContext(ctx, query, serviceName)
323
    if err != nil {
324
        return nil, contextutils.WrapError(err, "failed to query usage stats by service")
325
    }
326
    defer func() {
327
        if closeErr := rows.Close(); closeErr != nil {
328
            s.logger.Error(ctx, "Failed to close rows", closeErr, map[string]interface{}{})
329
        }
330
    }()
331

332
    stats = []*UsageStats{}
333
    for rows.Next() {
334
        var stat UsageStats
335
        err := rows.Scan(
336
            &stat.ID, &stat.ServiceName, &stat.UsageType, &stat.UsageMonth,
337
            &stat.CharactersUsed, &stat.RequestsMade, &stat.CreatedAt, &stat.UpdatedAt,
338
        )
339
        if err != nil {
340
            return nil, contextutils.WrapError(err, "failed to scan usage stats")
341
        }
342
        stats = append(stats, &stat)
343
    }
344

345
    if err := rows.Err(); err != nil {
346
        return nil, contextutils.WrapError(err, "error iterating usage stats")
347
    }
348

349
    return stats, nil
350
}
351

352
// GetUsageStatsByMonth returns usage statistics for a specific month
353
func (s *UsageStatsService) GetUsageStatsByMonth(ctx context.Context, year, month int) (stats []*UsageStats, err error) {
354
    ctx, span := observability.TraceUsageStatsFunction(ctx, "get_usage_stats_by_month",
355
        attribute.Int("year", year),
356
        attribute.Int("month", month),
357
    )
358
    defer observability.FinishSpan(span, &err)
359

360
    // Create date for the first day of the specified month
361
    targetMonth := time.Date(year, time.Month(month), 1, 0, 0, 0, 0, time.UTC)
362

363
    query := `
364
        SELECT id, service_name, usage_type, usage_month, characters_used, requests_made, created_at, updated_at
365
        FROM usage_stats
366
        WHERE usage_month = $1
367
        ORDER BY service_name, usage_type`
368

369
    rows, err := s.db.QueryContext(ctx, query, targetMonth)
370
    if err != nil {
371
        return nil, contextutils.WrapError(err, "failed to query usage stats by month")
372
    }
373
    defer func() {
374
        if closeErr := rows.Close(); closeErr != nil {
375
            s.logger.Error(ctx, "Failed to close rows", closeErr, map[string]interface{}{})
376
        }
377
    }()
378

379
    stats = []*UsageStats{}
380
    for rows.Next() {
381
        var stat UsageStats
382
        err := rows.Scan(
383
            &stat.ID, &stat.ServiceName, &stat.UsageType, &stat.UsageMonth,
384
            &stat.CharactersUsed, &stat.RequestsMade, &stat.CreatedAt, &stat.UpdatedAt,
385
        )
386
        if err != nil {
387
            return nil, contextutils.WrapError(err, "failed to scan usage stats")
388
        }
389
        stats = append(stats, &stat)
390
    }
391

392
    if err := rows.Err(); err != nil {
393
        return nil, contextutils.WrapError(err, "error iterating usage stats")
394
    }
395

396
    return stats, nil
397
}
398

399
// GetUserAITokenUsageStats returns AI token usage statistics for a specific user
400
func (s *UsageStatsService) GetUserAITokenUsageStats(ctx context.Context, userID int, startDate, endDate time.Time) (stats []*UserUsageStats, err error) {
401
    ctx, span := observability.TraceUsageStatsFunction(ctx, "get_user_ai_token_usage_stats",
402
        attribute.Int("user_id", userID),
403
        attribute.String("start_date", startDate.Format("2006-01-02")),
404
        attribute.String("end_date", endDate.Format("2006-01-02")),
405
    )
406
    defer observability.FinishSpan(span, &err)
407

408
    query := `
409
        SELECT id, user_id, api_key_id, usage_date, usage_hour, service_name, provider, model, usage_type, prompt_tokens, completion_tokens, total_tokens, requests_made, created_at, updated_at
410
        FROM user_usage_stats
411
        WHERE user_id = $1 AND usage_date >= $2 AND usage_date <= $3
412
        ORDER BY usage_date DESC, usage_hour DESC`
413

414
    rows, err := s.db.QueryContext(ctx, query, userID, startDate, endDate)
415
    if err != nil {
416
        return nil, contextutils.WrapError(err, "failed to query user usage stats")
417
    }
418
    defer func() {
419
        if closeErr := rows.Close(); closeErr != nil {
420
            s.logger.Warn(ctx, "Failed to close user usage stats query", map[string]interface{}{
421
                "error": closeErr.Error(),
422
            })
423
        }
424
    }()
425

426
    stats = []*UserUsageStats{}
427
    for rows.Next() {
428
        var stat UserUsageStats
429
        err = rows.Scan(
430
            &stat.ID, &stat.UserID, &stat.APIKeyID, &stat.UsageDate, &stat.UsageHour,
431
            &stat.ServiceName, &stat.Provider, &stat.Model, &stat.UsageType,
432
            &stat.PromptTokens, &stat.CompletionTokens, &stat.TotalTokens, &stat.RequestsMade,
433
            &stat.CreatedAt, &stat.UpdatedAt,
434
        )
435
        if err != nil {
436
            return nil, contextutils.WrapError(err, "failed to scan user usage stats")
437
        }
438
        stats = append(stats, &stat)
439
    }
440

441
    if err = rows.Err(); err != nil {
442
        return nil, contextutils.WrapError(err, "error iterating user usage stats")
443
    }
444

445
    return stats, nil
446
}
447

448
// GetUserAITokenUsageStatsByDay returns daily aggregated AI token usage for a user
449
func (s *UsageStatsService) GetUserAITokenUsageStatsByDay(ctx context.Context, userID int, startDate, endDate time.Time) (stats []*UserUsageStatsDaily, err error) {
450
    ctx, span := observability.TraceUsageStatsFunction(ctx, "get_user_ai_token_usage_stats_by_day",
451
        attribute.Int("user_id", userID),
452
        attribute.String("start_date", startDate.Format("2006-01-02")),
453
        attribute.String("end_date", endDate.Format("2006-01-02")),
454
    )
455
    defer observability.FinishSpan(span, &err)
456

457
    query := `
458
        SELECT usage_date, service_name, provider, model, usage_type,
459
               SUM(prompt_tokens) as total_prompt_tokens,
460
               SUM(completion_tokens) as total_completion_tokens,
461
               SUM(total_tokens) as total_tokens,
462
               SUM(requests_made) as total_requests
463
        FROM user_usage_stats
464
        WHERE user_id = $1 AND usage_date >= $2 AND usage_date <= $3
465
        GROUP BY usage_date, service_name, provider, model, usage_type
466
        ORDER BY usage_date DESC, service_name, provider, model, usage_type`
467

468
    rows, err := s.db.QueryContext(ctx, query, userID, startDate, endDate)
469
    if err != nil {
470
        return nil, contextutils.WrapError(err, "failed to query user daily usage stats")
471
    }
472
    defer func() {
473
        if closeErr := rows.Close(); closeErr != nil {
474
            s.logger.Warn(ctx, "Failed to close user daily usage stats query", map[string]interface{}{
475
                "error": closeErr.Error(),
476
            })
477
        }
478
    }()
479

480
    stats = []*UserUsageStatsDaily{}
481
    for rows.Next() {
482
        var stat UserUsageStatsDaily
483
        var usageDate time.Time
484
        err = rows.Scan(
485
            &usageDate, &stat.ServiceName, &stat.Provider, &stat.Model, &stat.UsageType,
486
            &stat.TotalPromptTokens, &stat.TotalCompletionTokens, &stat.TotalTokens, &stat.TotalRequests,
487
        )
488
        if err != nil {
489
            return nil, contextutils.WrapError(err, "failed to scan user daily usage stats")
490
        }
491
        stat.UsageDate = openapi_types.Date{Time: usageDate}
492
        stats = append(stats, &stat)
493
    }
494

495
    if err = rows.Err(); err != nil {
496
        return nil, contextutils.WrapError(err, "error iterating user daily usage stats")
497
    }
498

499
    return stats, nil
500
}
501

502
// GetUserAITokenUsageStatsByHour returns hourly aggregated AI token usage for a user on a specific day
503
func (s *UsageStatsService) GetUserAITokenUsageStatsByHour(ctx context.Context, userID int, date time.Time) (stats []*UserUsageStatsHourly, err error) {
504
    ctx, span := observability.TraceUsageStatsFunction(ctx, "get_user_ai_token_usage_stats_by_hour",
505
        attribute.Int("user_id", userID),
506
        attribute.String("date", date.Format("2006-01-02")),
507
    )
508
    defer observability.FinishSpan(span, &err)
509

510
    startOfDay := date.Truncate(24 * time.Hour)
511
    endOfDay := startOfDay.Add(24 * time.Hour).Add(-time.Nanosecond)
512

513
    query := `
514
        SELECT usage_hour, service_name, provider, model, usage_type,
515
               SUM(prompt_tokens) as total_prompt_tokens,
516
               SUM(completion_tokens) as total_completion_tokens,
517
               SUM(total_tokens) as total_tokens,
518
               SUM(requests_made) as total_requests
519
        FROM user_usage_stats
520
        WHERE user_id = $1 AND usage_date >= $2 AND usage_date <= $3
521
        GROUP BY usage_hour, service_name, provider, model, usage_type
522
        ORDER BY usage_hour, service_name, provider, model, usage_type`
523

524
    rows, err := s.db.QueryContext(ctx, query, userID, startOfDay, endOfDay)
525
    if err != nil {
526
        return nil, contextutils.WrapError(err, "failed to query user hourly usage stats")
527
    }
528
    defer func() {
529
        if closeErr := rows.Close(); closeErr != nil {
530
            s.logger.Warn(ctx, "Failed to close user hourly usage stats query", map[string]interface{}{
531
                "error": closeErr.Error(),
532
            })
533
        }
534
    }()
535

536
    stats = []*UserUsageStatsHourly{}
537
    for rows.Next() {
538
        var stat UserUsageStatsHourly
539
        err = rows.Scan(
540
            &stat.UsageHour, &stat.ServiceName, &stat.Provider, &stat.Model, &stat.UsageType,
541
            &stat.TotalPromptTokens, &stat.TotalCompletionTokens, &stat.TotalTokens, &stat.TotalRequests,
542
        )
543
        if err != nil {
544
            return nil, contextutils.WrapError(err, "failed to scan user hourly usage stats")
545
        }
546
        stats = append(stats, &stat)
547
    }
548

549
    if err = rows.Err(); err != nil {
550
        return nil, contextutils.WrapError(err, "error iterating user hourly usage stats")
551
    }
552

553
    return stats, nil
554
}
555

556
// NoopUsageStatsService is a no-operation implementation for testing and when quotas are disabled
557
type NoopUsageStatsService struct{}
558

559
// NewNoopUsageStatsService creates a new noop usage stats service
560
60x
func NewNoopUsageStatsService() *NoopUsageStatsService {
561
60x
    return &NoopUsageStatsService{}
562
60x
}
563

564
// CheckQuota always returns nil (no quota checking)
565
func (s *NoopUsageStatsService) CheckQuota(_ context.Context, _, _ string, _ int) (err error) {
566
    return nil
567
}
568

569
// RecordUsage always returns nil (no usage recording)
570
func (s *NoopUsageStatsService) RecordUsage(_ context.Context, _, _ string, _, _ int) (err error) {
571
    return nil
572
}
573

574
// GetCurrentMonthUsage returns empty stats
575
func (s *NoopUsageStatsService) GetCurrentMonthUsage(_ context.Context, _, _ string) (stats *UsageStats, err error) {
576
    return &UsageStats{
577
        ServiceName:    "",
578
        UsageType:      "",
579
        CharactersUsed: 0,
580
        RequestsMade:   0,
581
    }, nil
582
}
583

584
// GetMonthlyQuota always returns 0 (no quota limit)
585
func (s *NoopUsageStatsService) GetMonthlyQuota(_ string) int64 {
586
    return 0
587
}
588

589
// GetAllUsageStats returns all usage statistics (for admin interface)
590
func (s *NoopUsageStatsService) GetAllUsageStats(_ context.Context) ([]*UsageStats, error) {
591
    return []*UsageStats{}, nil
592
}
593

594
// GetUsageStatsByService returns usage statistics for a specific service
595
func (s *NoopUsageStatsService) GetUsageStatsByService(_ context.Context, _ string) ([]*UsageStats, error) {
596
    return []*UsageStats{}, nil
597
}
598

599
// GetUsageStatsByMonth returns usage statistics for a specific month
600
func (s *NoopUsageStatsService) GetUsageStatsByMonth(_ context.Context, _, _ int) ([]*UsageStats, error) {
601
    return []*UsageStats{}, nil
602
}
603

604
// RecordUserAITokenUsage always returns nil (no usage recording)
605
func (s *NoopUsageStatsService) RecordUserAITokenUsage(_ context.Context, _ int, _ *int, _, _, _ string, _, _, _, _ int) error {
606
    return nil
607
}
608

609
// GetUserAITokenUsageStats returns empty stats
610
func (s *NoopUsageStatsService) GetUserAITokenUsageStats(_ context.Context, _ int, _, _ time.Time) ([]*UserUsageStats, error) {
611
    return []*UserUsageStats{}, nil
612
}
613

614
// GetUserAITokenUsageStatsByDay returns empty stats
615
func (s *NoopUsageStatsService) GetUserAITokenUsageStatsByDay(_ context.Context, _ int, _, _ time.Time) ([]*UserUsageStatsDaily, error) {
616
    return []*UserUsageStatsDaily{}, nil
617
}
618

619
// GetUserAITokenUsageStatsByHour returns empty stats
620
func (s *NoopUsageStatsService) GetUserAITokenUsageStatsByHour(_ context.Context, _ int, _ time.Time) ([]*UserUsageStatsHourly, error) {
621
    return []*UserUsageStatsHourly{}, nil
622
}
623


			
quizapp internal services worker_service.go
62.3%
Statements
484/777
1
package services
2

3
import (
4
    "context"
5
    "database/sql"
6
    "errors"
7
    "fmt"
8
    "strings"
9
    "time"
10

11
    "quizapp/internal/config"
12
    "quizapp/internal/models"
13
    "quizapp/internal/observability"
14
    contextutils "quizapp/internal/utils"
15

16
    "github.com/lib/pq"
17

18
    "go.opentelemetry.io/otel/attribute"
19
    "go.opentelemetry.io/otel/codes"
20
    "go.opentelemetry.io/otel/trace"
21
    "golang.org/x/crypto/bcrypt"
22
)
23

24
// UserServiceInterface defines the interface for user-related operations.
25
// This allows for easier mocking in tests.
26
type UserServiceInterface interface {
27
    CreateUserWithPassword(ctx context.Context, username, password, language, level string) (*models.User, error)
28
    CreateUserWithEmailAndTimezone(ctx context.Context, username, email, timezone, language, level string) (*models.User, error)
29
    GetUserByID(ctx context.Context, id int) (*models.User, error)
30
    GetUserByUsername(ctx context.Context, username string) (*models.User, error)
31
    GetUserByEmail(ctx context.Context, email string) (*models.User, error)
32
    AuthenticateUser(ctx context.Context, username, password string) (*models.User, error)
33
    UpdateUserSettings(ctx context.Context, userID int, settings *models.UserSettings) error
34
    UpdateUserProfile(ctx context.Context, userID int, username, email, timezone string) error
35
    UpdateUserPassword(ctx context.Context, userID int, newPassword string) error
36
    UpdateLastActive(ctx context.Context, userID int) error
37
    GetAllUsers(ctx context.Context) ([]models.User, error)
38
    GetUsersPaginated(ctx context.Context, page, pageSize int, search, language, level, aiProvider, aiModel, aiEnabled, active string) ([]models.User, int, error)
39
    DeleteUser(ctx context.Context, userID int) error
40
    DeleteAllUsers(ctx context.Context) error
41
    EnsureAdminUserExists(ctx context.Context, adminUsername, adminPassword string) error
42
    ResetDatabase(ctx context.Context) error
43
    ClearUserData(ctx context.Context) error
44
    ClearUserDataForUser(ctx context.Context, userID int) error
45
    GetUserAPIKey(ctx context.Context, userID int, provider string) (string, error)
46
    GetUserAPIKeyWithID(ctx context.Context, userID int, provider string) (string, *int, error)
47
    SetUserAPIKey(ctx context.Context, userID int, provider, apiKey string) error
48
    HasUserAPIKey(ctx context.Context, userID int, provider string) (bool, error)
49
    // Role management methods
50
    GetUserRoles(ctx context.Context, userID int) ([]models.Role, error)
51
    GetAllRoles(ctx context.Context) ([]models.Role, error)
52
    AssignRole(ctx context.Context, userID, roleID int) error
53
    AssignRoleByName(ctx context.Context, userID int, roleName string) error
54
    RemoveRole(ctx context.Context, userID, roleID int) error
55
    HasRole(ctx context.Context, userID int, roleName string) (bool, error)
56
    IsAdmin(ctx context.Context, userID int) (bool, error)
57
    GetDB() *sql.DB
58
    UpdateWordOfDayEmailEnabled(ctx context.Context, userID int, enabled bool) error
59
}
60

61
// UserService provides methods for user management.
62
type UserService struct {
63
    db     *sql.DB
64
    cfg    *config.Config
65
    logger *observability.Logger
66
}
67

68
// Shared query constants to eliminate duplication
69
const (
70
    // userSelectFields contains all user fields for SELECT queries
71
    userSelectFields = `id, username, email, timezone, password_hash, last_active, preferred_language, current_level, ai_provider, ai_model, ai_enabled, ai_api_key, word_of_day_email_enabled, created_at, updated_at`
72

73
    // userSelectFieldsNoPassword contains user fields excluding password_hash for GetAllUsers
74
    userSelectFieldsNoPassword = `id, username, email, timezone, last_active, preferred_language, current_level, ai_provider, ai_model, ai_enabled, ai_api_key, word_of_day_email_enabled, created_at, updated_at`
75
)
76

77
// scanUserFromRow scans a database row into a models.User struct
78
361x
func (s *UserService) scanUserFromRow(row *sql.Row) (result0 *models.User, err error) {
79
361x
    user := &models.User{}
80
361x
    err = row.Scan(
81
361x
        &user.ID, &user.Username, &user.Email, &user.Timezone, &user.PasswordHash, &user.LastActive,
82
361x
        &user.PreferredLanguage, &user.CurrentLevel, &user.AIProvider,
83
361x
        &user.AIModel, &user.AIEnabled, &user.AIAPIKey, &user.WordOfDayEmailEnabled, &user.CreatedAt, &user.UpdatedAt,
84
361x
    )
85
361x
    if err != nil {
86
19x
        return nil, err
87
19x
    }
88
342x
    return user, nil
89
}
90

91
// scanUserFromRowsNoPassword scans a database rows into a models.User struct (without password_hash)
92
16x
func (s *UserService) scanUserFromRowsNoPassword(rows *sql.Rows) (result0 *models.User, err error) {
93
16x
    user := &models.User{}
94
16x
    err = rows.Scan(
95
16x
        &user.ID, &user.Username, &user.Email, &user.Timezone, &user.LastActive,
96
16x
        &user.PreferredLanguage, &user.CurrentLevel, &user.AIProvider,
97
16x
        &user.AIModel, &user.AIEnabled, &user.AIAPIKey, &user.WordOfDayEmailEnabled, &user.CreatedAt, &user.UpdatedAt,
98
16x
    )
99
16x
    if err != nil {
100
        return nil, err
101
    }
102
16x
    return user, nil
103
}
104

105
// getUserByQuery is a shared method for getting a user by any query
106
361x
func (s *UserService) getUserByQuery(ctx context.Context, query string, args ...interface{}) (result0 *models.User, err error) {
107
361x
    row := s.db.QueryRowContext(ctx, query, args...)
108
361x
    var user *models.User
109
361x
    user, err = s.scanUserFromRow(row)
110
361x
    if err != nil {
111
19x
        if errors.Is(err, sql.ErrNoRows) {
112
19x
            return nil, nil // User not found is not an error here
113
19x
        }
114
        return nil, err
115
    }
116

117
    // Try to apply default settings, but don't fail if there's an issue
118
342x
    s.applyDefaultSettings(ctx, user)
119
342x
    return user, nil
120
}
121

122
// NewUserServiceWithLogger creates a new UserService instance with logger
123
148x
func NewUserServiceWithLogger(db *sql.DB, cfg *config.Config, logger *observability.Logger) *UserService {
124
148x
    return &UserService{
125
148x
        db:     db,
126
148x
        cfg:    cfg,
127
148x
        logger: logger,
128
148x
    }
129
148x
}
130

131
// CreateUser creates a new user with the specified username, language, and level
132
// Only used for testing purposes, should be moved to test utils if possible.
133
72x
func (s *UserService) CreateUser(ctx context.Context, username, language, level string) (result0 *models.User, err error) {
134
72x
    ctx, span := observability.TraceUserFunction(ctx, "create_user", attribute.String("user.username", username))
135
72x
    defer observability.FinishSpan(span, &err)
136
72x

137
72x
    // Validate username is not empty
138
72x
    if username == "" || len(strings.TrimSpace(username)) == 0 {
139
1x
        return nil, contextutils.WrapError(contextutils.ErrInvalidInput, "username cannot be empty")
140
1x
    }
141

142
    // default timezone to UTC for new users
143
71x
    query := `INSERT INTO users (username, preferred_language, current_level, last_active, created_at, updated_at, timezone) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id`
144
71x
    now := time.Now()
145
71x
    var id int
146
71x
    err = s.db.QueryRowContext(ctx, query, username, language, level, now, now, now, "UTC").Scan(&id)
147
71x
    if err != nil {
148
1x
        return nil, err
149
1x
    }
150
70x
    var user *models.User
151
70x
    user, err = s.GetUserByID(ctx, id)
152
70x
    if err != nil {
153
        return nil, err
154
    }
155
70x
    if user == nil {
156
        return nil, contextutils.WrapError(contextutils.ErrDatabaseQuery, "user was created but could not be retrieved from database")
157
    }
158
70x
    return user, nil
159
}
160

161
// CreateUserWithEmailAndTimezone creates a new user with email and timezone
162
21x
func (s *UserService) CreateUserWithEmailAndTimezone(ctx context.Context, username, email, timezone, language, level string) (result0 *models.User, err error) {
163
21x
    ctx, span := observability.TraceUserFunction(ctx, "create_user_with_email", attribute.String("user.username", username))
164
21x
    defer observability.FinishSpan(span, &err)
165
21x

166
21x
    // Validate username is not empty
167
21x
    if username == "" || len(strings.TrimSpace(username)) == 0 {
168
        return nil, contextutils.WrapError(contextutils.ErrInvalidInput, "username cannot be empty")
169
    }
170

171
21x
    query := `INSERT INTO users (username, email, timezone, preferred_language, current_level, ai_enabled, last_active, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING id`
172
21x
    now := time.Now()
173
21x
    var id int
174
21x
    err = s.db.QueryRowContext(ctx, query, username, email, timezone, language, level, false, now, now, now).Scan(&id)
175
21x
    if err != nil {
176
        if isDuplicateKeyError(err) {
177
            return nil, contextutils.ErrRecordExists
178
        }
179
        return nil, err
180
    }
181
21x
    if err != nil {
182
        return nil, err
183
    }
184
21x
    var user *models.User
185
21x
    user, err = s.GetUserByID(ctx, id)
186
21x
    if err != nil {
187
        return nil, err
188
    }
189
21x
    if user == nil {
190
        return nil, contextutils.WrapError(contextutils.ErrDatabaseQuery, "user was created but could not be retrieved from database")
191
    }
192

193
    // Assign default "user" role to new users
194
21x
    err = s.AssignRoleByName(ctx, user.ID, "user")
195
21x
    if err != nil {
196
        // Log the error but don't fail the user creation
197
        // The user role assignment can be done manually by admin if needed
198
        s.logger.Warn(ctx, "Failed to assign default user role", map[string]interface{}{
199
            "user_id": user.ID,
200
            "error":   err.Error(),
201
        })
202
    }
203

204
21x
    return user, nil
205
}
206

207
// CreateUserWithPassword creates a new user with password authentication
208
80x
func (s *UserService) CreateUserWithPassword(ctx context.Context, username, password, language, level string) (result0 *models.User, err error) {
209
80x
    ctx, span := observability.TraceUserFunction(ctx, "create_user_with_password", attribute.String("user.username", username))
210
80x
    defer observability.FinishSpan(span, &err)
211
80x

212
80x
    // Validate username is not empty
213
80x
    if username == "" || len(strings.TrimSpace(username)) == 0 {
214
        return nil, contextutils.WrapError(contextutils.ErrInvalidInput, "username cannot be empty")
215
    }
216

217
    // Hash the password using bcrypt
218
80x
    var hashedPassword []byte
219
80x
    hashedPassword, err = bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
220
80x
    if err != nil {
221
        return nil, err
222
    }
223

224
    // default timezone to UTC for new users created with password
225
80x
    query := `INSERT INTO users (username, password_hash, preferred_language, current_level, ai_enabled, last_active, created_at, updated_at, timezone) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING id`
226
80x
    now := time.Now()
227
80x
    var id int
228
80x
    err = s.db.QueryRowContext(ctx, query, username, string(hashedPassword), language, level, false, now, now, now, "UTC").Scan(&id)
229
80x
    if err != nil {
230
        if isDuplicateKeyError(err) {
231
            return nil, contextutils.ErrRecordExists
232
        }
233
        return nil, err
234
    }
235
80x
    if err != nil {
236
        return nil, err
237
    }
238
80x
    user, err := s.GetUserByID(ctx, id)
239
80x
    if err != nil {
240
        return nil, err
241
    }
242
80x
    if user == nil {
243
        return nil, contextutils.WrapError(contextutils.ErrDatabaseQuery, "user was created but could not be retrieved from database")
244
    }
245

246
    // Assign default "user" role to new users
247
80x
    err = s.AssignRoleByName(ctx, user.ID, "user")
248
80x
    if err != nil {
249
        // Log the error but don't fail the user creation
250
        // The user role assignment can be done manually by admin if needed
251
        s.logger.Warn(ctx, "Failed to assign default user role", map[string]interface{}{
252
            "user_id": user.ID,
253
            "error":   err.Error(),
254
        })
255
    }
256

257
80x
    return user, nil
258
}
259

260
// AuthenticateUser verifies user credentials and returns the user if valid
261
9x
func (s *UserService) AuthenticateUser(ctx context.Context, username, password string) (result0 *models.User, err error) {
262
9x
    ctx, span := observability.TraceUserFunction(ctx, "authenticate_user", attribute.String("user.username", username))
263
9x
    defer observability.FinishSpan(span, &err)
264
9x
    // Get user by username
265
9x
    var user *models.User
266
9x
    user, err = s.GetUserByUsername(ctx, username)
267
9x
    if err != nil {
268
        return nil, err
269
    }
270
9x
    if user == nil {
271
1x
        return nil, errors.New("user not found")
272
1x
    }
273

274
    // Check if password hash exists
275
8x
    if !user.PasswordHash.Valid {
276
1x
        return nil, errors.New("user has no password set")
277
1x
    }
278

279
    // Compare provided password with stored hash
280
7x
    err = bcrypt.CompareHashAndPassword([]byte(user.PasswordHash.String), []byte(password))
281
7x
    if err != nil {
282
3x
        return nil, errors.New("invalid password")
283
3x
    }
284

285
4x
    return user, nil
286
}
287

288
// GetUserByID retrieves a user by their ID
289
342x
func (s *UserService) GetUserByID(ctx context.Context, id int) (result0 *models.User, err error) {
290
342x
    ctx, span := observability.TraceUserFunction(ctx, "get_user_by_id", attribute.Int("user.id", id))
291
342x
    defer observability.FinishSpan(span, &err)
292
342x
    query := fmt.Sprintf("SELECT %s FROM users WHERE id = $1", userSelectFields)
293
342x
    var user *models.User
294
342x
    user, err = s.getUserByQuery(ctx, query, id)
295
342x
    if err != nil {
296
        s.logger.Error(ctx, "Database error retrieving user", err, map[string]interface{}{"user_id": id})
297
        return nil, err
298
    }
299
342x
    if user == nil {
300
14x
        s.logger.Debug(ctx, "User not found in database", map[string]interface{}{"user_id": id})
301
14x
        return nil, nil
302
14x
    }
303

304
    // Load user roles
305
328x
    roles, err := s.GetUserRoles(ctx, id)
306
328x
    if err != nil {
307
        s.logger.Warn(ctx, "Failed to load user roles", map[string]interface{}{"user_id": id, "error": err.Error()})
308
        // Don't fail the entire request if roles can't be loaded
309
        user.Roles = []models.Role{}
310
    } else {
311
328x
        user.Roles = roles
312
328x
    }
313

314
328x
    return user, nil
315
}
316

317
// GetUserByUsername retrieves a user by their username
318
15x
func (s *UserService) GetUserByUsername(ctx context.Context, username string) (result0 *models.User, err error) {
319
15x
    ctx, span := observability.TraceUserFunction(ctx, "get_user_by_username", attribute.String("user.username", username))
320
15x
    defer observability.FinishSpan(span, &err)
321
15x
    query := fmt.Sprintf("SELECT %s FROM users WHERE username = $1", userSelectFields)
322
15x
    return s.getUserByQuery(ctx, query, username)
323
15x
}
324

325
// UpdateUserSettings updates user settings including AI configuration
326
10x
func (s *UserService) UpdateUserSettings(ctx context.Context, userID int, settings *models.UserSettings) (err error) {
327
10x
    ctx, span := observability.TraceUserFunction(ctx, "update_user_settings", attribute.Int("user.id", userID))
328
10x
    defer observability.FinishSpan(span, &err)
329
10x

330
10x
    // Check if user exists before updating settings
331
10x
    user, err := s.GetUserByID(ctx, userID)
332
10x
    if err != nil {
333
        return contextutils.WrapError(err, "failed to check if user exists")
334
    }
335
10x
    if user == nil {
336
1x
        return contextutils.WrapError(contextutils.ErrRecordNotFound, "user not found")
337
1x
    }
338

339
    // Start a transaction to update both user settings and API key
340
9x
    var tx *sql.Tx
341
9x
    tx, err = s.db.Begin()
342
9x
    if err != nil {
343
        return contextutils.WrapError(err, "failed to begin transaction for user settings update")
344
    }
345
9x
    defer func() {
346
9x
        if rollbackErr := tx.Rollback(); rollbackErr != nil && rollbackErr != sql.ErrTxDone {
347
            s.logger.Warn(ctx, "Warning: failed to rollback transaction", map[string]interface{}{"error": rollbackErr.Error()})
348
        }
349
    }()
350

351
    // Handle AI enabled logic
352
9x
    aiProvider := settings.AIProvider
353
9x
    aiModel := settings.AIModel
354
9x

355
9x
    // If AI is disabled, clear the provider and model
356
9x
    if !settings.AIEnabled {
357
2x
        aiProvider = ""
358
2x
        aiModel = ""
359
2x
    }
360

361
    // Update user settings (excluding API key which is now stored separately)
362
9x
    query := `UPDATE users SET preferred_language = $1, current_level = $2, ai_provider = $3, ai_model = $4, ai_enabled = $5, updated_at = $6 WHERE id = $7`
363
9x
    var result sql.Result
364
9x
    result, err = tx.ExecContext(ctx, query, settings.Language, settings.Level, aiProvider, aiModel, settings.AIEnabled, time.Now(), userID)
365
9x
    if err != nil {
366
        return contextutils.WrapError(err, "failed to update user settings in transaction")
367
    }
368

369
    // Check if the user was actually updated
370
9x
    rowsAffected, err := result.RowsAffected()
371
9x
    if err != nil {
372
        return contextutils.WrapError(err, "failed to get rows affected")
373
    }
374

375
9x
    if rowsAffected == 0 {
376
        return contextutils.WrapErrorf(contextutils.ErrRecordNotFound, "user with ID %d not found", userID)
377
    }
378

379
    // If an API key is provided and AI is enabled, save it for the specific provider
380
9x
    if settings.AIAPIKey != "" && settings.AIProvider != "" && settings.AIEnabled {
381
1x
        err = s.setUserAPIKeyTx(ctx, tx, userID, settings.AIProvider, settings.AIAPIKey)
382
1x
        if err != nil {
383
            return contextutils.WrapError(err, "failed to set user API key in transaction")
384
        }
385
    }
386

387
9x
    return tx.Commit()
388
}
389

390
// UpdateWordOfDayEmailEnabled updates the user's preference for word-of-day emails
391
2x
func (s *UserService) UpdateWordOfDayEmailEnabled(ctx context.Context, userID int, enabled bool) (err error) {
392
2x
    ctx, span := observability.TraceUserFunction(ctx, "update_word_of_day_email_enabled",
393
2x
        attribute.Int("user.id", userID),
394
2x
        attribute.Bool("word_of_day_email_enabled", enabled),
395
2x
    )
396
2x
    defer observability.FinishSpan(span, &err)
397
2x

398
2x
    // Ensure user exists
399
2x
    user, err := s.GetUserByID(ctx, userID)
400
2x
    if err != nil {
401
        return contextutils.WrapError(err, "failed to check if user exists")
402
    }
403
2x
    if user == nil {
404
        return contextutils.ErrRecordNotFound
405
    }
406

407
2x
    _, err = s.db.ExecContext(ctx, `UPDATE users SET word_of_day_email_enabled = $1, updated_at = NOW() WHERE id = $2`, enabled, userID)
408
2x
    if err != nil {
409
        return contextutils.WrapError(err, "failed to update word_of_day_email_enabled")
410
    }
411
2x
    return nil
412
}
413

414
// GetUserAPIKey retrieves the API key for a specific provider for a user
415
3x
func (s *UserService) GetUserAPIKey(ctx context.Context, userID int, provider string) (result0 string, err error) {
416
3x
    ctx, span := observability.TraceUserFunction(ctx, "get_user_api_key", attribute.Int("user.id", userID), attribute.String("user.provider", provider))
417
3x
    defer observability.FinishSpan(span, &err)
418
3x

419
3x
    // Check if user exists before getting API key
420
3x
    user, err := s.GetUserByID(ctx, userID)
421
3x
    if err != nil {
422
        return "", contextutils.WrapError(err, "failed to check if user exists")
423
    }
424
3x
    if user == nil {
425
2x
        return "", contextutils.WrapError(contextutils.ErrRecordNotFound, "user not found")
426
2x
    }
427

428
1x
    query := `SELECT api_key FROM user_api_keys WHERE user_id = $1 AND provider = $2`
429
1x
    var apiKey string
430
1x
    err = s.db.QueryRowContext(ctx, query, userID, provider).Scan(&apiKey)
431
1x
    if err != nil {
432
1x
        if errors.Is(err, sql.ErrNoRows) {
433
1x
            return "", contextutils.WrapError(contextutils.ErrRecordNotFound, "API key for provider not found")
434
1x
        }
435
        return "", contextutils.WrapError(err, "failed to get user API key")
436
    }
437
    return apiKey, nil
438
}
439

440
// GetUserAPIKeyWithID retrieves the API key and its ID for a specific provider for a user
441
func (s *UserService) GetUserAPIKeyWithID(ctx context.Context, userID int, provider string) (apiKey string, apiKeyID *int, err error) {
442
    ctx, span := observability.TraceUserFunction(ctx, "get_user_api_key_with_id", attribute.Int("user.id", userID), attribute.String("user.provider", provider))
443
    defer observability.FinishSpan(span, &err)
444

445
    // Check if user exists before getting API key
446
    user, err := s.GetUserByID(ctx, userID)
447
    if err != nil {
448
        return "", nil, contextutils.WrapError(err, "failed to check if user exists")
449
    }
450
    if user == nil {
451
        return "", nil, contextutils.WrapError(contextutils.ErrRecordNotFound, "user not found")
452
    }
453

454
    query := `SELECT id, api_key FROM user_api_keys WHERE user_id = $1 AND provider = $2`
455
    var id int
456
    var key string
457
    err = s.db.QueryRowContext(ctx, query, userID, provider).Scan(&id, &key)
458
    if err != nil {
459
        if errors.Is(err, sql.ErrNoRows) {
460
            return "", nil, contextutils.WrapError(contextutils.ErrRecordNotFound, "API key for provider not found")
461
        }
462
        return "", nil, contextutils.WrapError(err, "failed to get user API key with ID")
463
    }
464
    return key, &id, nil
465
}
466

467
// SetUserAPIKey sets the API key for a specific provider for a user
468
3x
func (s *UserService) SetUserAPIKey(ctx context.Context, userID int, provider, apiKey string) (err error) {
469
3x
    ctx, span := observability.TraceUserFunction(ctx, "set_user_api_key", attribute.Int("user.id", userID), attribute.String("user.provider", provider))
470
3x
    defer observability.FinishSpan(span, &err)
471
3x

472
3x
    // Check if user exists before setting API key
473
3x
    user, err := s.GetUserByID(ctx, userID)
474
3x
    if err != nil {
475
        return contextutils.WrapError(err, "failed to check if user exists")
476
    }
477
3x
    if user == nil {
478
1x
        return contextutils.WrapError(contextutils.ErrRecordNotFound, "user not found")
479
1x
    }
480

481
2x
    var tx *sql.Tx
482
2x
    tx, err = s.db.Begin()
483
2x
    if err != nil {
484
        return contextutils.WrapError(err, "failed to begin transaction for API key update")
485
    }
486
2x
    defer func() {
487
2x
        if err != nil {
488
            if rollbackErr := tx.Rollback(); rollbackErr != nil {
489
                s.logger.Warn(ctx, "Warning: failed to rollback transaction", map[string]interface{}{"error": rollbackErr.Error()})
490
            }
491
        }
492
    }()
493

494
2x
    err = s.setUserAPIKeyTx(ctx, tx, userID, provider, apiKey)
495
2x
    if err != nil {
496
        return contextutils.WrapError(err, "failed to set user API key in transaction")
497
    }
498

499
2x
    commitErr := tx.Commit()
500
2x
    if commitErr != nil {
501
        return contextutils.WrapError(commitErr, "failed to commit API key transaction")
502
    }
503

504
    // Clear the error so defer doesn't try to rollback
505
2x
    err = nil
506
2x
    return nil
507
}
508

509
// setUserAPIKeyTx sets the API key for a specific provider within a transaction
510
3x
func (s *UserService) setUserAPIKeyTx(ctx context.Context, tx *sql.Tx, userID int, provider, apiKey string) error {
511
3x
    query := `INSERT INTO user_api_keys (user_id, provider, api_key, updated_at)
512
3x
              VALUES ($1, $2, $3, $4)
513
3x
              ON CONFLICT (user_id, provider)
514
3x
              DO UPDATE SET api_key = $3, updated_at = $4`
515
3x
    _, err := tx.ExecContext(ctx, query, userID, provider, apiKey, time.Now())
516
3x
    return contextutils.WrapError(err, "failed to execute API key transaction")
517
3x
}
518

519
// HasUserAPIKey checks if a user has an API key for a specific provider
520
1x
func (s *UserService) HasUserAPIKey(ctx context.Context, userID int, provider string) (result0 bool, err error) {
521
1x
    ctx, span := observability.TraceUserFunction(ctx, "has_user_api_key", attribute.Int("user.id", userID), attribute.String("user.provider", provider))
522
1x
    defer observability.FinishSpan(span, &err)
523
1x
    var apiKey string
524
1x
    apiKey, err = s.GetUserAPIKey(ctx, userID, provider)
525
1x
    if err != nil {
526
1x
        // If the error is "not found" and it's specifically about the API key not existing (not the user),
527
1x
        // then it means no API key exists, which is not an error
528
1x
        if errors.Is(err, contextutils.ErrRecordNotFound) {
529
1x
            // Check if the error message indicates it's about the API key, not the user
530
1x
            if strings.Contains(err.Error(), "API key for provider not found") {
531
                return false, nil
532
            }
533
            // If it's about the user not found, return the error
534
1x
            return false, err
535
        }
536
        return false, contextutils.WrapError(err, "failed to check if user has API key")
537
    }
538
    return apiKey != "", nil
539
}
540

541
// UpdateLastActive updates the user's last activity timestamp
542
1x
func (s *UserService) UpdateLastActive(ctx context.Context, userID int) (err error) {
543
1x
    ctx, span := observability.TraceUserFunction(ctx, "update_last_active", attribute.Int("user.id", userID))
544
1x
    defer observability.FinishSpan(span, &err)
545
1x
    query := `UPDATE users SET last_active = $1 WHERE id = $2`
546
1x
    var result sql.Result
547
1x
    result, err = s.db.ExecContext(ctx, query, time.Now(), userID)
548
1x
    if err != nil {
549
        return contextutils.WrapError(err, "failed to update user last active timestamp")
550
    }
551

552
    // Check if the user was actually updated
553
1x
    rowsAffected, err := result.RowsAffected()
554
1x
    if err != nil {
555
        return contextutils.WrapError(err, "failed to get rows affected")
556
    }
557

558
1x
    if rowsAffected == 0 {
559
        return contextutils.WrapErrorf(contextutils.ErrRecordNotFound, "user with ID %d not found", userID)
560
    }
561

562
1x
    return nil
563
}
564

565
// GetAllUsers retrieves all users from the database
566
4x
func (s *UserService) GetAllUsers(ctx context.Context) (result0 []models.User, err error) {
567
4x
    ctx, span := observability.TraceUserFunction(ctx, "get_all_users")
568
4x
    defer observability.FinishSpan(span, &err)
569
4x
    query := fmt.Sprintf("SELECT %s FROM users", userSelectFieldsNoPassword)
570
4x
    var rows *sql.Rows
571
4x
    rows, err = s.db.QueryContext(ctx, query)
572
4x
    if err != nil {
573
        return nil, contextutils.WrapError(err, "failed to query all users")
574
    }
575
4x
    defer func() {
576
4x
        if err = rows.Close(); err != nil {
577
            s.logger.Warn(ctx, "Warning: failed to close rows", map[string]interface{}{"error": err.Error()})
578
        }
579
    }()
580

581
4x
    var users []models.User
582
4x
    for rows.Next() {
583
16x
        user, err := s.scanUserFromRowsNoPassword(rows)
584
16x
        if err != nil {
585
            return nil, contextutils.WrapError(err, "failed to scan user from rows")
586
        }
587

588
        // Load user roles
589
16x
        roles, err := s.GetUserRoles(ctx, user.ID)
590
16x
        if err != nil {
591
            s.logger.Warn(ctx, "Failed to load user roles", map[string]interface{}{"user_id": user.ID, "error": err.Error()})
592
            // Don't fail the entire request if roles can't be loaded
593
            user.Roles = []models.Role{}
594
        } else {
595
16x
            user.Roles = roles
596
16x
        }
597

598
16x
        users = append(users, *user)
599
    }
600

601
4x
    return users, nil
602
}
603

604
// GetUsersPaginated retrieves paginated users with filtering and search
605
func (s *UserService) GetUsersPaginated(ctx context.Context, page, pageSize int, search, language, level, aiProvider, aiModel, aiEnabled, active string) (result0 []models.User, result1 int, err error) {
606
    ctx, span := observability.TraceUserFunction(ctx, "get_users_paginated")
607
    defer observability.FinishSpan(span, &err)
608

609
    // Build WHERE clause and args
610
    var conditions []string
611
    var args []interface{}
612
    argIndex := 1
613

614
    // Search filter
615
    if search != "" {
616
        conditions = append(conditions, fmt.Sprintf("(username ILIKE $%d OR email ILIKE $%d)", argIndex, argIndex))
617
        args = append(args, "%"+search+"%")
618
        argIndex++
619
    }
620

621
    // Language filter
622
    if language != "" {
623
        conditions = append(conditions, fmt.Sprintf("preferred_language = $%d", argIndex))
624
        args = append(args, language)
625
        argIndex++
626
    }
627

628
    // Level filter
629
    if level != "" {
630
        conditions = append(conditions, fmt.Sprintf("current_level = $%d", argIndex))
631
        args = append(args, level)
632
        argIndex++
633
    }
634

635
    // AI Provider filter
636
    if aiProvider != "" {
637
        conditions = append(conditions, fmt.Sprintf("ai_provider = $%d", argIndex))
638
        args = append(args, aiProvider)
639
        argIndex++
640
    }
641

642
    // AI Model filter
643
    if aiModel != "" {
644
        conditions = append(conditions, fmt.Sprintf("ai_model = $%d", argIndex))
645
        args = append(args, aiModel)
646
        argIndex++
647
    }
648

649
    // AI Enabled filter
650
    if aiEnabled != "" {
651
        enabled := aiEnabled == "true"
652
        conditions = append(conditions, fmt.Sprintf("ai_enabled = $%d", argIndex))
653
        args = append(args, enabled)
654
        argIndex++
655
    }
656

657
    // Active filter (based on last_active within 7 days)
658
    if active != "" {
659
        activeThreshold := time.Now().AddDate(0, 0, -7)
660
        switch active {
661
        case "true":
662
            conditions = append(conditions, fmt.Sprintf("last_active >= $%d", argIndex))
663
            args = append(args, activeThreshold)
664
        case "false":
665
            conditions = append(conditions, fmt.Sprintf("(last_active < $%d OR last_active IS NULL)", argIndex))
666
            args = append(args, activeThreshold)
667
        }
668
        argIndex++
669
    }
670

671
    // Build WHERE clause
672
    whereClause := ""
673
    if len(conditions) > 0 {
674
        whereClause = "WHERE " + strings.Join(conditions, " AND ")
675
    }
676

677
    // Get total count
678
    countQuery := fmt.Sprintf("SELECT COUNT(*) FROM users %s", whereClause)
679
    var total int
680
    err = s.db.QueryRowContext(ctx, countQuery, args...).Scan(&total)
681
    if err != nil {
682
        return nil, 0, contextutils.WrapError(err, "failed to count users")
683
    }
684

685
    // Get paginated results
686
    offset := (page - 1) * pageSize
687
    query := fmt.Sprintf("SELECT %s FROM users %s ORDER BY username LIMIT $%d OFFSET $%d",
688
        userSelectFieldsNoPassword, whereClause, argIndex, argIndex+1)
689
    args = append(args, pageSize, offset)
690

691
    rows, err := s.db.QueryContext(ctx, query, args...)
692
    if err != nil {
693
        return nil, 0, contextutils.WrapError(err, "failed to query paginated users")
694
    }
695
    defer func() {
696
        if closeErr := rows.Close(); closeErr != nil {
697
            s.logger.Warn(ctx, "Warning: failed to close rows", map[string]interface{}{"error": closeErr.Error()})
698
        }
699
    }()
700

701
    var users []models.User
702
    for rows.Next() {
703
        user, err := s.scanUserFromRowsNoPassword(rows)
704
        if err != nil {
705
            return nil, 0, contextutils.WrapError(err, "failed to scan user from rows")
706
        }
707

708
        // Load user roles
709
        roles, err := s.GetUserRoles(ctx, user.ID)
710
        if err != nil {
711
            s.logger.Warn(ctx, "Failed to load user roles", map[string]interface{}{"user_id": user.ID, "error": err.Error()})
712
            // Don't fail the entire request if roles can't be loaded
713
            user.Roles = []models.Role{}
714
        } else {
715
            user.Roles = roles
716
        }
717

718
        users = append(users, *user)
719
    }
720

721
    return users, total, nil
722
}
723

724
// GetUserByEmail retrieves a user by their email address
725
4x
func (s *UserService) GetUserByEmail(ctx context.Context, email string) (result0 *models.User, err error) {
726
4x
    ctx, span := observability.TraceUserFunction(ctx, "get_user_by_email", attribute.String("user.email", email))
727
4x
    defer observability.FinishSpan(span, &err)
728
4x
    query := fmt.Sprintf("SELECT %s FROM users WHERE email = $1", userSelectFields)
729
4x
    return s.getUserByQuery(ctx, query, email)
730
4x
}
731

732
// UpdateUserProfile updates user profile information (username, email, timezone)
733
1x
func (s *UserService) UpdateUserProfile(ctx context.Context, userID int, username, email, timezone string) (err error) {
734
1x
    ctx, span := observability.TraceUserFunction(ctx, "update_user_profile", attribute.Int("user.id", userID))
735
1x
    defer observability.FinishSpan(span, &err)
736
1x
    query := `UPDATE users SET username = $1, email = $2, timezone = $3, updated_at = $4 WHERE id = $5`
737
1x
    var result sql.Result
738
1x
    result, err = s.db.ExecContext(ctx, query, username, email, timezone, time.Now(), userID)
739
1x
    if err != nil {
740
        return contextutils.WrapError(err, "failed to update user profile")
741
    }
742

743
    // Check if the user was actually updated
744
1x
    rowsAffected, err := result.RowsAffected()
745
1x
    if err != nil {
746
        return contextutils.WrapError(err, "failed to get rows affected")
747
    }
748

749
1x
    if rowsAffected == 0 {
750
        return contextutils.WrapErrorf(contextutils.ErrRecordNotFound, "user with ID %d not found", userID)
751
    }
752

753
1x
    return nil
754
}
755

756
// UpdateUserPassword updates a user's password
757
5x
func (s *UserService) UpdateUserPassword(ctx context.Context, userID int, newPassword string) (err error) {
758
5x
    ctx, span := observability.TraceUserFunction(ctx, "update_user_password", attribute.Int("user.id", userID))
759
5x
    defer observability.FinishSpan(span, &err)
760
5x

761
5x
    // Validate password is not empty
762
5x
    if newPassword == "" {
763
1x
        return contextutils.ErrorWithContextf("password cannot be empty")
764
1x
    }
765

766
    // Check if user exists first
767
4x
    user, err := s.GetUserByID(ctx, userID)
768
4x
    if err != nil {
769
        return contextutils.WrapError(err, "failed to check if user exists")
770
    }
771
4x
    if user == nil {
772
1x
        return contextutils.WrapError(contextutils.ErrRecordNotFound, "user not found")
773
1x
    }
774

775
    // Hash the new password using bcrypt
776
3x
    var hashedPassword []byte
777
3x
    hashedPassword, err = bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
778
3x
    if err != nil {
779
        return contextutils.WrapError(err, "failed to hash password")
780
    }
781

782
3x
    query := `UPDATE users SET password_hash = $1, updated_at = $2 WHERE id = $3`
783
3x
    result, err := s.db.ExecContext(ctx, query, string(hashedPassword), time.Now(), userID)
784
3x
    if err != nil {
785
        return contextutils.WrapError(err, "failed to update user password")
786
    }
787

788
    // Check if any rows were affected
789
3x
    rowsAffected, err := result.RowsAffected()
790
3x
    if err != nil {
791
        return contextutils.WrapError(err, "failed to get rows affected")
792
    }
793

794
3x
    if rowsAffected == 0 {
795
        return contextutils.WrapError(contextutils.ErrRecordNotFound, "user not found")
796
    }
797

798
3x
    s.logger.Info(ctx, "Password updated successfully", map[string]interface{}{"user_id": userID, "username": user.Username})
799
3x
    return nil
800
}
801

802
// DeleteUser removes a user and their associated data
803
3x
func (s *UserService) DeleteUser(ctx context.Context, userID int) (err error) {
804
3x
    ctx, span := observability.TraceUserFunction(ctx, "delete_user", attribute.Int("user.id", userID))
805
3x
    defer observability.FinishSpan(span, &err)
806
3x

807
3x
    // Check if user exists before deleting
808
3x
    user, err := s.GetUserByID(ctx, userID)
809
3x
    if err != nil {
810
        return contextutils.WrapError(err, "failed to check if user exists")
811
    }
812
3x
    if user == nil {
813
1x
        return contextutils.WrapError(contextutils.ErrRecordNotFound, "user not found")
814
1x
    }
815

816
    // Best-effort cleanup of dependent rows for tables that may not have ON DELETE CASCADE in some environments
817
    // This keeps tests deterministic and avoids orphaned data
818
    // TODO: This is a hack to make the tests deterministic. We should use ON DELETE CASCADE instead.
819
2x
    cleanupQueries := []string{
820
2x
        `DELETE FROM question_reports WHERE reported_by_user_id = $1`,
821
2x
        `DELETE FROM user_api_keys WHERE user_id = $1`,
822
2x
        `DELETE FROM user_roles WHERE user_id = $1`,
823
2x
        `DELETE FROM user_learning_preferences WHERE user_id = $1`,
824
2x
        `DELETE FROM question_priority_scores WHERE user_id = $1`,
825
2x
        `DELETE FROM user_question_metadata WHERE user_id = $1`,
826
2x
        `DELETE FROM user_responses WHERE user_id = $1`,
827
2x
        `DELETE FROM user_questions WHERE user_id = $1`,
828
2x
    }
829
2x
    for _, q := range cleanupQueries {
830
16x
        if _, err := s.db.ExecContext(ctx, q, userID); err != nil {
831
            s.logger.Warn(ctx, "Non-fatal cleanup failure during user delete", map[string]interface{}{"error": err.Error(), "query": q, "user_id": userID})
832
        }
833
    }
834

835
    // Delete the user
836
2x
    query := `DELETE FROM users WHERE id = $1`
837
2x
    result, err := s.db.ExecContext(ctx, query, userID)
838
2x
    if err != nil {
839
        return contextutils.WrapError(err, "failed to delete user")
840
    }
841

842
2x
    rowsAffected, err := result.RowsAffected()
843
2x
    if err != nil {
844
        return contextutils.WrapError(err, "failed to get rows affected")
845
    }
846

847
2x
    if rowsAffected == 0 {
848
        return contextutils.WrapError(contextutils.ErrRecordNotFound, "user not found")
849
    }
850

851
2x
    s.logger.Info(ctx, "User %d deleted successfully", map[string]interface{}{"user_id": userID})
852
2x
    return nil
853
}
854

855
// DeleteAllUsers removes all users from the database
856
2x
func (s *UserService) DeleteAllUsers(ctx context.Context) (err error) {
857
2x
    ctx, span := observability.TraceUserFunction(ctx, "delete_all_users")
858
2x
    defer observability.FinishSpan(span, &err)
859
2x
    var tx *sql.Tx
860
2x
    tx, err = s.db.Begin()
861
2x
    if err != nil {
862
        return contextutils.WrapError(err, "failed to begin transaction for delete all users")
863
    }
864
2x
    defer func() {
865
2x
        if err != nil {
866
            if rollbackErr := tx.Rollback(); rollbackErr != nil {
867
                s.logger.Warn(ctx, "Warning: failed to rollback transaction", map[string]interface{}{"error": rollbackErr.Error()})
868
            }
869
        }
870
    }()
871

872
    // Whitelist of valid table names to prevent SQL injection
873
2x
    validTables := map[string]bool{
874
2x
        "user_responses":      true,
875
2x
        "performance_metrics": true,
876
2x
        "users":               true,
877
2x
    }
878
2x

879
2x
    // Delete all data in the correct order (to respect foreign key constraints)
880
2x
    tables := []string{
881
2x
        "user_responses",
882
2x
        "performance_metrics",
883
2x
        "users",
884
2x
    }
885
2x

886
2x
    for _, table := range tables {
887
6x
        // Validate table name against whitelist
888
6x
        if !validTables[table] {
889
            return contextutils.ErrorWithContextf("invalid table name: %s", table)
890
        }
891

892
        // Use parameterized query with validated table name
893
6x
        query := fmt.Sprintf("DELETE FROM %s", table)
894
6x
        if _, err := tx.ExecContext(ctx, query); err != nil {
895
            return contextutils.WrapErrorf(err, "failed to delete from table %s", table)
896
        }
897
        // Reset sequence for PostgreSQL
898
6x
        sequenceQuery := fmt.Sprintf("ALTER SEQUENCE %s_id_seq RESTART WITH 1", table)
899
6x
        if _, err := tx.ExecContext(ctx, sequenceQuery); err != nil {
900
            // This might fail if the table doesn't have a sequence, so we log but don't fail
901
            s.logger.Warn(ctx, "Note: Could not reset sequence for %s (this is normal for some tables)", map[string]interface{}{"table": table})
902
        }
903
    }
904

905
2x
    return contextutils.WrapError(tx.Commit(), "failed to commit delete all users transaction")
906
}
907

908
// EnsureAdminUserExists creates the admin user if it doesn't exist
909
5x
func (s *UserService) EnsureAdminUserExists(ctx context.Context, adminUsername, adminPassword string) (err error) {
910
5x
    ctx, span := observability.TraceUserFunction(ctx, "ensure_admin_user_exists", attribute.String("admin.username", adminUsername))
911
5x
    defer observability.FinishSpan(span, &err)
912
5x

913
5x
    // Validate input parameters
914
5x
    if adminUsername == "" {
915
1x
        return contextutils.ErrorWithContextf("admin username cannot be empty")
916
1x
    }
917

918
4x
    if adminPassword == "" {
919
1x
        return contextutils.ErrorWithContextf("admin password cannot be empty")
920
1x
    }
921
    // Check if admin user already exists
922
3x
    var existingUser *models.User
923
3x
    existingUser, err = s.GetUserByUsername(ctx, adminUsername)
924
3x
    if err != nil {
925
        return contextutils.WrapError(err, "failed to check if admin user exists")
926
    }
927

928
3x
    if existingUser != nil {
929
1x
        // User exists, check if password needs to be updated
930
1x
        if existingUser.PasswordHash.Valid {
931
1x
            // User has a password, test if it matches current admin password
932
1x
            err = bcrypt.CompareHashAndPassword([]byte(existingUser.PasswordHash.String), []byte(adminPassword))
933
1x
            if err == nil {
934
                // Password matches, ensure AI settings are configured
935
                err = s.ensureAdminAISettings(ctx, existingUser.ID)
936
                if err != nil {
937
                    s.logger.Warn(ctx, "Warning: Failed to set AI settings for existing admin user", map[string]interface{}{"error": err.Error()})
938
                }
939

940
                // Ensure admin user has email and timezone if not set
941
                if !existingUser.Email.Valid || !existingUser.Timezone.Valid {
942
                    err = s.ensureAdminProfile(ctx, existingUser.ID)
943
                    if err != nil {
944
                        s.logger.Warn(ctx, "Warning: Failed to update admin profile", map[string]interface{}{"error": err.Error()})
945
                    }
946
                }
947

948
                // Ensure admin user has admin role
949
                isAdmin, err := s.IsAdmin(ctx, existingUser.ID)
950
                if err != nil {
951
                    s.logger.Warn(ctx, "Warning: Failed to check admin role for existing admin user", map[string]interface{}{"error": err.Error()})
952
                } else if !isAdmin {
953
                    err = s.AssignRoleByName(ctx, existingUser.ID, "admin")
954
                    if err != nil {
955
                        s.logger.Warn(ctx, "Warning: Failed to assign admin role to existing admin user", map[string]interface{}{"error": err.Error()})
956
                    }
957
                }
958

959
                s.logger.Info(ctx, "Admin user already exists with correct password", map[string]interface{}{"username": adminUsername})
960
                return nil
961
            }
962
        }
963

964
        // Update password
965
1x
        hashedPassword, err := bcrypt.GenerateFromPassword([]byte(adminPassword), bcrypt.DefaultCost)
966
1x
        if err != nil {
967
            return contextutils.WrapError(err, "failed to hash admin password")
968
        }
969

970
1x
        query := `UPDATE users SET password_hash = $1, updated_at = $2 WHERE username = $3`
971
1x
        _, err = s.db.ExecContext(ctx, query, string(hashedPassword), time.Now(), adminUsername)
972
1x
        if err != nil {
973
            return contextutils.WrapError(err, "failed to update admin user password")
974
        }
975

976
        // Ensure AI settings are configured
977
1x
        err = s.ensureAdminAISettings(ctx, existingUser.ID)
978
1x
        if err != nil {
979
            s.logger.Warn(ctx, "Warning: Failed to set AI settings for existing admin user", map[string]interface{}{"error": err.Error()})
980
        }
981

982
        // Ensure admin user has email and timezone if not set
983
1x
        if !existingUser.Email.Valid || !existingUser.Timezone.Valid {
984
            err = s.ensureAdminProfile(ctx, existingUser.ID)
985
            if err != nil {
986
                s.logger.Warn(ctx, "Warning: Failed to update admin profile", map[string]interface{}{"error": err.Error()})
987
            }
988
        }
989

990
        // Ensure admin user has admin role
991
1x
        isAdmin, err := s.IsAdmin(ctx, existingUser.ID)
992
1x
        if err != nil {
993
            s.logger.Warn(ctx, "Warning: Failed to check admin role for existing admin user", map[string]interface{}{"error": err.Error()})
994
        } else if !isAdmin {
995
            err = s.AssignRoleByName(ctx, existingUser.ID, "admin")
996
            if err != nil {
997
                s.logger.Warn(ctx, "Warning: Failed to assign admin role to existing admin user", map[string]interface{}{"error": err.Error()})
998
            }
999
        }
1000

1001
1x
        s.logger.Info(ctx, "Updated password for admin user", map[string]interface{}{"username": adminUsername})
1002
1x
        return nil
1003
    }
1004

1005
    // Create new admin user with email and timezone
1006
2x
    user, err := s.CreateUserWithEmailAndTimezone(ctx, adminUsername, "admin@example.com", "America/New_York", "italian", "A1")
1007
2x
    if err != nil {
1008
        return contextutils.WrapError(err, "failed to create admin user")
1009
    }
1010

1011
    // Set password for the admin user
1012
2x
    hashedPassword, err := bcrypt.GenerateFromPassword([]byte(adminPassword), bcrypt.DefaultCost)
1013
2x
    if err != nil {
1014
        return contextutils.WrapError(err, "failed to hash new admin password")
1015
    }
1016

1017
2x
    query := `UPDATE users SET password_hash = $1, updated_at = $2 WHERE id = $3`
1018
2x
    _, err = s.db.ExecContext(ctx, query, string(hashedPassword), time.Now(), user.ID)
1019
2x
    if err != nil {
1020
        return contextutils.WrapError(err, "failed to set password for new admin user")
1021
    }
1022

1023
    // Set up AI settings for the admin user
1024
2x
    err = s.ensureAdminAISettings(ctx, user.ID)
1025
2x
    if err != nil {
1026
        s.logger.Warn(ctx, "Warning: Failed to set AI settings for new admin user", map[string]interface{}{"error": err.Error()})
1027
    }
1028

1029
    // Assign admin role to the admin user
1030
2x
    err = s.AssignRoleByName(ctx, user.ID, "admin")
1031
2x
    if err != nil {
1032
        s.logger.Warn(ctx, "Warning: Failed to assign admin role to new admin user", map[string]interface{}{"error": err.Error()})
1033
    }
1034

1035
2x
    s.logger.Info(ctx, "Created admin user", map[string]interface{}{"username": adminUsername})
1036
2x
    return nil
1037
}
1038

1039
// ensureAdminAISettings ensures the admin user has AI settings configured
1040
// Only sets default values if the user doesn't already have AI settings configured
1041
3x
func (s *UserService) ensureAdminAISettings(ctx context.Context, userID int) (err error) {
1042
3x
    ctx, span := observability.TraceUserFunction(ctx, "ensure_admin_ai_settings", attribute.Int("user.id", userID))
1043
3x
    defer observability.FinishSpan(span, &err)
1044
3x
    var user *models.User
1045
3x
    user, err = s.GetUserByID(ctx, userID)
1046
3x
    if err != nil {
1047
        return err
1048
    }
1049
3x
    if user == nil {
1050
        return errors.New("admin user not found")
1051
    }
1052

1053
    // If user already has AI provider configured, don't override their settings
1054
3x
    if user.AIProvider.Valid && user.AIProvider.String != "" {
1055
1x
        s.logger.Info(ctx, "User ID already has AI settings configured, preserving existing settings", map[string]interface{}{"user_id": userID, "provider": user.AIProvider.String})
1056
1x
        return nil
1057
1x
    }
1058

1059
    // Set default AI settings with a default API key
1060
2x
    settings := &models.UserSettings{
1061
2x
        AIProvider: "ollama",
1062
2x
        AIModel:    "llama4:latest",
1063
2x
        AIAPIKey:   "not_needed", // Default API key
1064
2x
    }
1065
2x

1066
2x
    // Only update AI settings, preserve other user settings
1067
2x
    query := `UPDATE users SET ai_provider = $1, ai_model = $2, ai_api_key = $3, updated_at = $4 WHERE id = $5`
1068
2x
    _, err = s.db.ExecContext(ctx, query, settings.AIProvider, settings.AIModel, settings.AIAPIKey, time.Now(), userID)
1069
2x
    if err != nil {
1070
        return contextutils.WrapError(err, "failed to update user AI settings")
1071
    }
1072

1073
    // Save the API key to the user_api_keys table
1074
2x
    err = s.SetUserAPIKey(ctx, userID, settings.AIProvider, settings.AIAPIKey)
1075
2x
    if err != nil {
1076
        s.logger.Warn(ctx, "Warning: Failed to save API key for user %d", map[string]interface{}{"user_id": userID, "error": err.Error()})
1077
    }
1078

1079
2x
    s.logger.Info(ctx, "Set default AI settings for user", map[string]interface{}{"user_id": userID, "provider": settings.AIProvider, "model": settings.AIModel})
1080
2x
    return nil
1081
}
1082

1083
// ensureAdminProfile ensures the admin user has email and timezone set
1084
func (s *UserService) ensureAdminProfile(ctx context.Context, userID int) (err error) {
1085
    ctx, span := observability.TraceUserFunction(ctx, "ensure_admin_profile", attribute.Int("user.id", userID))
1086
    defer observability.FinishSpan(span, &err)
1087
    query := `UPDATE users SET email = $1, timezone = $2, updated_at = $3 WHERE id = $4 AND (email IS NULL OR timezone IS NULL)`
1088
    _, err = s.db.ExecContext(ctx, query, "admin@example.com", "America/New_York", time.Now(), userID)
1089
    if err != nil {
1090
        return contextutils.WrapError(err, "failed to update admin profile")
1091
    }
1092

1093
    s.logger.Info(ctx, "Updated admin user profile with default email and timezone", map[string]interface{}{"user_id": userID})
1094
    return nil
1095
}
1096

1097
// ResetDatabase completely resets the database to an empty state
1098
func (s *UserService) ResetDatabase(ctx context.Context) (err error) {
1099
    ctx, span := observability.TraceUserFunction(ctx, "reset_database")
1100
    defer observability.FinishSpan(span, &err)
1101
    var tx *sql.Tx
1102
    tx, err = s.db.Begin()
1103
    if err != nil {
1104
        return contextutils.WrapError(err, "failed to begin transaction for database reset")
1105
    }
1106
    defer func() {
1107
        if rollbackErr := tx.Rollback(); rollbackErr != nil && rollbackErr != sql.ErrTxDone {
1108
            s.logger.Warn(ctx, "Warning: failed to rollback transaction", map[string]interface{}{"error": rollbackErr.Error()})
1109
        }
1110
    }()
1111

1112
    // Whitelist of valid table names to prevent SQL injection
1113
    validTables := map[string]bool{
1114
        "user_responses":      true,
1115
        "performance_metrics": true,
1116
        "questions":           true,
1117
        "users":               true,
1118
    }
1119

1120
    // Delete all data in the correct order (to respect foreign key constraints)
1121
    tables := []string{
1122
        "user_responses",
1123
        "performance_metrics",
1124
        "questions",
1125
        "users",
1126
    }
1127

1128
    for _, table := range tables {
1129
        // Validate table name against whitelist
1130
        if !validTables[table] {
1131
            return contextutils.ErrorWithContextf("invalid table name: %s", table)
1132
        }
1133

1134
        // Use parameterized query with validated table name
1135
        query := fmt.Sprintf("DELETE FROM %s", table)
1136
        if _, err := tx.ExecContext(ctx, query); err != nil {
1137
            return contextutils.WrapErrorf(err, "failed to delete from table %s during reset", table)
1138
        }
1139
        s.logger.Info(ctx, "Cleared table: %s", map[string]interface{}{"table": table})
1140

1141
        // Reset sequence for PostgreSQL
1142
        sequenceQuery := fmt.Sprintf("ALTER SEQUENCE %s_id_seq RESTART WITH 1", table)
1143
        if _, err := tx.ExecContext(ctx, sequenceQuery); err != nil {
1144
            // This might fail if the table doesn't have a sequence, so we log but don't fail
1145
            s.logger.Warn(ctx, "Note: Could not reset sequence for %s (this is normal for some tables)", map[string]interface{}{"table": table})
1146
        }
1147
    }
1148

1149
    err = tx.Commit()
1150
    if err != nil {
1151
        return contextutils.WrapError(err, "failed to commit database reset transaction")
1152
    }
1153

1154
    s.logger.Info(ctx, "Database reset completed successfully")
1155
    return nil
1156
}
1157

1158
// ClearUserData removes all user activity data but keeps the users themselves
1159
1x
func (s *UserService) ClearUserData(ctx context.Context) (err error) {
1160
1x
    ctx, span := observability.TraceUserFunction(ctx, "clear_user_data")
1161
1x
    defer observability.FinishSpan(span, &err)
1162
1x
    var tx *sql.Tx
1163
1x
    tx, err = s.db.Begin()
1164
1x
    if err != nil {
1165
        return contextutils.WrapError(err, "failed to begin transaction for clear user data")
1166
    }
1167
1x
    defer func() {
1168
1x
        if rollbackErr := tx.Rollback(); rollbackErr != nil && rollbackErr != sql.ErrTxDone {
1169
            s.logger.Warn(ctx, "Warning: failed to rollback transaction", map[string]interface{}{"error": rollbackErr.Error()})
1170
        }
1171
    }()
1172

1173
    // Whitelist of valid table names to prevent SQL injection
1174
1x
    validTables := map[string]bool{
1175
1x
        "user_responses":      true,
1176
1x
        "performance_metrics": true,
1177
1x
        "questions":           true,
1178
1x
    }
1179
1x

1180
1x
    // Delete user data but keep users (order matters due to foreign key constraints)
1181
1x
    tables := []string{
1182
1x
        "user_responses",
1183
1x
        "performance_metrics",
1184
1x
        "questions",
1185
1x
    }
1186
1x

1187
1x
    for _, table := range tables {
1188
3x
        // Validate table name against whitelist
1189
3x
        if !validTables[table] {
1190
            return contextutils.ErrorWithContextf("invalid table name: %s", table)
1191
        }
1192

1193
        // Use parameterized query with validated table name
1194
3x
        query := fmt.Sprintf("DELETE FROM %s", table)
1195
3x
        if _, err := tx.ExecContext(ctx, query); err != nil {
1196
            return contextutils.WrapErrorf(err, "failed to delete from table %s during clear user data", table)
1197
        }
1198
3x
        s.logger.Info(ctx, "Cleared table: %s", map[string]interface{}{"table": table})
1199
3x

1200
3x
        // Reset sequence for PostgreSQL
1201
3x
        sequenceQuery := fmt.Sprintf("ALTER SEQUENCE %s_id_seq RESTART WITH 1", table)
1202
3x
        if _, err := tx.ExecContext(ctx, sequenceQuery); err != nil {
1203
            // This might fail if the table doesn't have a sequence, so we log but don't fail
1204
            s.logger.Warn(ctx, "Note: Could not reset sequence for %s (this is normal for some tables)", map[string]interface{}{"table": table})
1205
        }
1206
    }
1207

1208
1x
    err = tx.Commit()
1209
1x
    if err != nil {
1210
        return contextutils.WrapError(err, "failed to commit clear user data transaction")
1211
    }
1212

1213
1x
    s.logger.Info(ctx, "User data cleared successfully (users preserved)")
1214
1x
    return nil
1215
}
1216

1217
// ClearUserDataForUser removes all user activity data for a specific user but keeps the user record
1218
2x
func (s *UserService) ClearUserDataForUser(ctx context.Context, userID int) (err error) {
1219
2x
    ctx, span := observability.TraceUserFunction(ctx, "clear_user_data_for_user", attribute.Int("user.id", userID))
1220
2x
    defer observability.FinishSpan(span, &err)
1221
2x
    var tx *sql.Tx
1222
2x
    tx, err = s.db.Begin()
1223
2x
    if err != nil {
1224
        s.logger.Warn(ctx, "Failed to begin transaction", map[string]interface{}{"error": err.Error()})
1225
        return contextutils.WrapError(err, "failed to begin transaction for clear user data for specific user")
1226
    }
1227
2x
    defer func() {
1228
2x
        if rollbackErr := tx.Rollback(); rollbackErr != nil && rollbackErr != sql.ErrTxDone {
1229
            s.logger.Warn(ctx, "Warning: failed to rollback transaction", map[string]interface{}{"error": rollbackErr.Error()})
1230
        }
1231
    }()
1232

1233
    // Delete user_responses for this user's questions (via user_questions)
1234
2x
    query := `DELETE FROM user_responses WHERE question_id IN (SELECT question_id FROM user_questions WHERE user_id = $1)`
1235
2x
    result, err := tx.ExecContext(ctx, query, userID)
1236
2x
    if err != nil {
1237
        s.logger.Warn(ctx, "Failed to delete user_responses", map[string]interface{}{"error": err.Error()})
1238
        return contextutils.WrapError(err, "failed to delete user responses for specific user")
1239
    }
1240
2x
    rows, _ := result.RowsAffected()
1241
2x
    s.logger.Info(ctx, "Deleted %d user_responses for user %d", map[string]interface{}{"count": rows, "user_id": userID})
1242
2x

1243
2x
    // Delete performance_metrics for this user (performance_metrics has user_id, not question_id)
1244
2x
    query = `DELETE FROM performance_metrics WHERE user_id = $1`
1245
2x
    result, err = tx.ExecContext(ctx, query, userID)
1246
2x
    if err != nil {
1247
        s.logger.Warn(ctx, "Failed to delete performance_metrics", map[string]interface{}{"error": err.Error()})
1248
        return contextutils.WrapError(err, "failed to delete performance metrics for specific user")
1249
    }
1250
2x
    rows, _ = result.RowsAffected()
1251
2x
    s.logger.Info(ctx, "Deleted %d performance_metrics for user %d", map[string]interface{}{"count": rows, "user_id": userID})
1252
2x

1253
2x
    // Delete user_questions for this user
1254
2x
    query = `DELETE FROM user_questions WHERE user_id = $1`
1255
2x
    result, err = tx.ExecContext(ctx, query, userID)
1256
2x
    if err != nil {
1257
        s.logger.Warn(ctx, "Failed to delete user_questions", map[string]interface{}{"error": err.Error()})
1258
        return contextutils.WrapError(err, "failed to delete user questions for specific user")
1259
    }
1260
2x
    rows, _ = result.RowsAffected()
1261
2x
    s.logger.Info(ctx, "Deleted %d user_questions for user %d", map[string]interface{}{"count": rows, "user_id": userID})
1262
2x

1263
2x
    // Optionally, delete orphaned questions (not assigned to any user)
1264
2x
    query = `DELETE FROM questions WHERE id NOT IN (SELECT question_id FROM user_questions)`
1265
2x
    result, err = tx.ExecContext(ctx, query)
1266
2x
    if err != nil {
1267
        s.logger.Warn(ctx, "Failed to delete orphaned questions", map[string]interface{}{"error": err.Error()})
1268
        return contextutils.WrapError(err, "failed to delete orphaned questions")
1269
    }
1270
2x
    rows, _ = result.RowsAffected()
1271
2x
    s.logger.Info(ctx, "Deleted %d orphaned questions", map[string]interface{}{"count": rows})
1272
2x

1273
2x
    if err := tx.Commit(); err != nil {
1274
        s.logger.Warn(ctx, "Failed to commit transaction", map[string]interface{}{"error": err.Error()})
1275
        return contextutils.WrapError(err, "failed to commit clear user data for specific user transaction")
1276
    }
1277
2x
    s.logger.Info(ctx, "User data cleared successfully for user %d (users preserved)", map[string]interface{}{"user_id": userID})
1278
2x
    return nil
1279
}
1280

1281
342x
func (s *UserService) applyDefaultSettings(ctx context.Context, user *models.User) {
1282
342x
    if user == nil || s.cfg == nil {
1283
        return
1284
    }
1285
342x
    _, span := observability.TraceUserFunction(ctx, "apply_default_settings", attribute.Int("user.id", user.ID))
1286
342x
    defer span.End()
1287
342x
    if user.AIProvider.String == "" && len(s.cfg.Providers) > 0 {
1288
298x
        // Use the first available provider as default
1289
298x
        provider := s.cfg.Providers[0]
1290
298x
        user.AIProvider.String = provider.Code
1291
298x
        // Use first model in the list as default
1292
298x
        if len(provider.Models) > 0 {
1293
298x
            user.AIModel.String = provider.Models[0].Code
1294
298x
        }
1295
    }
1296
342x
    if user.CurrentLevel.String == "" {
1297
1x
        // Set default level based on user's preferred language, or use first available language
1298
1x
        language := user.PreferredLanguage.String
1299
1x
        if language == "" {
1300
1x
            languages := s.cfg.GetLanguages()
1301
1x
            if len(languages) > 0 {
1302
1x
                language = languages[0]
1303
1x
            }
1304
        }
1305
1x
        if language != "" {
1306
1x
            levels := s.cfg.GetLevelsForLanguage(language)
1307
1x
            if len(levels) > 0 {
1308
1x
                user.CurrentLevel.String = levels[0]
1309
1x
            }
1310
        }
1311
    }
1312
342x
    if user.PreferredLanguage.String == "" {
1313
1x
        user.PreferredLanguage.String = "english"
1314
1x
    }
1315
}
1316

1317
// GetUserRoles retrieves all roles for a user
1318
360x
func (s *UserService) GetUserRoles(ctx context.Context, userID int) (result0 []models.Role, err error) {
1319
360x
    ctx, span := observability.TraceUserFunction(ctx, "get_user_roles", attribute.Int("user.id", userID))
1320
360x
    defer func() {
1321
360x
        if err != nil {
1322
            span.RecordError(err, trace.WithStackTrace(true))
1323
            span.SetStatus(codes.Error, err.Error())
1324
        }
1325
360x
        span.End()
1326
    }()
1327

1328
360x
    query := `
1329
360x
        SELECT r.id, r.name, r.description, r.created_at, r.updated_at
1330
360x
        FROM roles r
1331
360x
        JOIN user_roles ur ON r.id = ur.role_id
1332
360x
        WHERE ur.user_id = $1
1333
360x
        ORDER BY r.name
1334
360x
    `
1335
360x
    rows, err := s.db.QueryContext(ctx, query, userID)
1336
360x
    if err != nil {
1337
        return nil, contextutils.WrapError(err, "failed to get user roles")
1338
    }
1339
360x
    defer func() {
1340
360x
        if closeErr := rows.Close(); closeErr != nil {
1341
            s.logger.Warn(ctx, "Warning: failed to close rows", map[string]interface{}{"error": closeErr.Error()})
1342
        }
1343
    }()
1344

1345
360x
    var roles []models.Role
1346
360x
    for rows.Next() {
1347
88x
        var role models.Role
1348
88x
        err := rows.Scan(&role.ID, &role.Name, &role.Description, &role.CreatedAt, &role.UpdatedAt)
1349
88x
        if err != nil {
1350
            return nil, contextutils.WrapError(err, "failed to scan user role")
1351
        }
1352
88x
        roles = append(roles, role)
1353
    }
1354

1355
360x
    if err = rows.Err(); err != nil {
1356
        return nil, contextutils.WrapError(err, "error iterating user roles")
1357
    }
1358

1359
360x
    return roles, nil
1360
}
1361

1362
// AssignRole assigns a role to a user
1363
11x
func (s *UserService) AssignRole(ctx context.Context, userID, roleID int) (err error) {
1364
11x
    ctx, span := observability.TraceUserFunction(ctx, "assign_role", attribute.Int("user.id", userID), attribute.Int("role.id", roleID))
1365
11x
    defer func() {
1366
11x
        if err != nil {
1367
4x
            span.RecordError(err, trace.WithStackTrace(true))
1368
4x
            span.SetStatus(codes.Error, err.Error())
1369
4x
        }
1370
11x
        span.End()
1371
    }()
1372

1373
    // Check if user exists
1374
11x
    user, err := s.GetUserByID(ctx, userID)
1375
11x
    if err != nil {
1376
        return contextutils.WrapError(err, "failed to get user for role assignment")
1377
    }
1378
11x
    if user == nil {
1379
2x
        return contextutils.ErrorWithContextf("user with ID %d not found", userID)
1380
2x
    }
1381

1382
    // Check if role exists
1383
9x
    var roleName string
1384
9x
    err = s.db.QueryRowContext(ctx, "SELECT name FROM roles WHERE id = $1", roleID).Scan(&roleName)
1385
9x
    if err != nil {
1386
2x
        if errors.Is(err, sql.ErrNoRows) {
1387
2x
            return contextutils.ErrorWithContextf("role with ID %d not found", roleID)
1388
2x
        }
1389
        return contextutils.WrapError(err, "failed to check role existence")
1390
    }
1391

1392
    // Assign role (using ON CONFLICT DO NOTHING to handle duplicate assignments gracefully)
1393
7x
    query := `INSERT INTO user_roles (user_id, role_id, created_at) VALUES ($1, $2, $3) ON CONFLICT (user_id, role_id) DO NOTHING`
1394
7x
    _, err = s.db.ExecContext(ctx, query, userID, roleID, time.Now())
1395
7x
    if err != nil {
1396
        return contextutils.WrapError(err, "failed to assign role to user")
1397
    }
1398

1399
7x
    s.logger.Info(ctx, "Role assigned successfully", map[string]interface{}{
1400
7x
        "user_id":   userID,
1401
7x
        "role_id":   roleID,
1402
7x
        "role_name": roleName,
1403
7x
    })
1404
7x

1405
7x
    return nil
1406
}
1407

1408
// AssignRoleByName assigns a role to a user by role name
1409
110x
func (s *UserService) AssignRoleByName(ctx context.Context, userID int, roleName string) (err error) {
1410
110x
    ctx, span := observability.TraceUserFunction(ctx, "assign_role_by_name", attribute.Int("user.id", userID), attribute.String("role.name", roleName))
1411
110x
    defer func() {
1412
110x
        if err != nil {
1413
3x
            span.RecordError(err, trace.WithStackTrace(true))
1414
3x
            span.SetStatus(codes.Error, err.Error())
1415
3x
        }
1416
110x
        span.End()
1417
    }()
1418

1419
    // Check if user exists
1420
110x
    user, err := s.GetUserByID(ctx, userID)
1421
110x
    if err != nil {
1422
        return contextutils.WrapError(err, "failed to get user for role assignment")
1423
    }
1424
110x
    if user == nil {
1425
1x
        return contextutils.ErrorWithContextf("user with ID %d not found", userID)
1426
1x
    }
1427

1428
    // Get role ID by name
1429
109x
    var roleID int
1430
109x
    err = s.db.QueryRowContext(ctx, "SELECT id FROM roles WHERE name = $1", roleName).Scan(&roleID)
1431
109x
    if err != nil {
1432
2x
        if errors.Is(err, sql.ErrNoRows) {
1433
2x
            return contextutils.ErrorWithContextf("role with name '%s' not found", roleName)
1434
2x
        }
1435
        return contextutils.WrapError(err, "failed to get role ID by name")
1436
    }
1437

1438
    // Assign role (using ON CONFLICT DO NOTHING to handle duplicate assignments gracefully)
1439
107x
    query := `INSERT INTO user_roles (user_id, role_id, created_at) VALUES ($1, $2, $3) ON CONFLICT (user_id, role_id) DO NOTHING`
1440
107x
    _, err = s.db.ExecContext(ctx, query, userID, roleID, time.Now())
1441
107x
    if err != nil {
1442
        return contextutils.WrapError(err, "failed to assign role to user")
1443
    }
1444

1445
107x
    s.logger.Info(ctx, "Role assigned successfully", map[string]interface{}{
1446
107x
        "user_id":   userID,
1447
107x
        "role_id":   roleID,
1448
107x
        "role_name": roleName,
1449
107x
    })
1450
107x

1451
107x
    return nil
1452
}
1453

1454
// RemoveRole removes a role from a user
1455
4x
func (s *UserService) RemoveRole(ctx context.Context, userID, roleID int) (err error) {
1456
4x
    ctx, span := observability.TraceUserFunction(ctx, "remove_role", attribute.Int("user.id", userID), attribute.Int("role.id", roleID))
1457
4x
    defer func() {
1458
4x
        if err != nil {
1459
3x
            span.RecordError(err, trace.WithStackTrace(true))
1460
3x
            span.SetStatus(codes.Error, err.Error())
1461
3x
        }
1462
4x
        span.End()
1463
    }()
1464

1465
    // Check if user exists
1466
4x
    user, err := s.GetUserByID(ctx, userID)
1467
4x
    if err != nil {
1468
        return contextutils.WrapError(err, "failed to get user for role removal")
1469
    }
1470
4x
    if user == nil {
1471
1x
        return contextutils.ErrorWithContextf("user with ID %d not found", userID)
1472
1x
    }
1473

1474
    // Check if role exists
1475
3x
    var roleName string
1476
3x
    err = s.db.QueryRowContext(ctx, "SELECT name FROM roles WHERE id = $1", roleID).Scan(&roleName)
1477
3x
    if err != nil {
1478
1x
        if errors.Is(err, sql.ErrNoRows) {
1479
1x
            return contextutils.ErrorWithContextf("role with ID %d not found", roleID)
1480
1x
        }
1481
        return contextutils.WrapError(err, "failed to check role existence")
1482
    }
1483

1484
    // Remove role
1485
2x
    query := `DELETE FROM user_roles WHERE user_id = $1 AND role_id = $2`
1486
2x
    result, err := s.db.ExecContext(ctx, query, userID, roleID)
1487
2x
    if err != nil {
1488
        return contextutils.WrapError(err, "failed to remove role from user")
1489
    }
1490

1491
2x
    rowsAffected, err := result.RowsAffected()
1492
2x
    if err != nil {
1493
        return contextutils.WrapError(err, "failed to get rows affected")
1494
    }
1495

1496
2x
    if rowsAffected == 0 {
1497
1x
        return contextutils.ErrorWithContextf("user %d does not have role %d", userID, roleID)
1498
1x
    }
1499

1500
1x
    s.logger.Info(ctx, "Role removed successfully", map[string]interface{}{
1501
1x
        "user_id":   userID,
1502
1x
        "role_id":   roleID,
1503
1x
        "role_name": roleName,
1504
1x
    })
1505
1x

1506
1x
    return nil
1507
}
1508

1509
// HasRole checks if a user has a specific role by name
1510
19x
func (s *UserService) HasRole(ctx context.Context, userID int, roleName string) (result0 bool, err error) {
1511
19x
    ctx, span := observability.TraceUserFunction(ctx, "has_role", attribute.Int("user.id", userID), attribute.String("role.name", roleName))
1512
19x
    defer func() {
1513
19x
        if err != nil {
1514
            span.RecordError(err, trace.WithStackTrace(true))
1515
            span.SetStatus(codes.Error, err.Error())
1516
        }
1517
19x
        span.End()
1518
    }()
1519

1520
19x
    query := `
1521
19x
        SELECT COUNT(*) > 0
1522
19x
        FROM user_roles ur
1523
19x
        JOIN roles r ON ur.role_id = r.id
1524
19x
        WHERE ur.user_id = $1 AND r.name = $2
1525
19x
    `
1526
19x
    var hasRole bool
1527
19x
    err = s.db.QueryRowContext(ctx, query, userID, roleName).Scan(&hasRole)
1528
19x
    if err != nil {
1529
        return false, contextutils.WrapError(err, "failed to check if user has role")
1530
    }
1531

1532
19x
    return hasRole, nil
1533
}
1534

1535
// IsAdmin checks if a user has admin role
1536
8x
func (s *UserService) IsAdmin(ctx context.Context, userID int) (result0 bool, err error) {
1537
8x
    ctx, span := observability.TraceUserFunction(ctx, "is_admin", attribute.Int("user.id", userID))
1538
8x
    defer observability.FinishSpan(span, &err)
1539
8x

1540
8x
    return s.HasRole(ctx, userID, "admin")
1541
8x
}
1542

1543
// GetAllRoles returns all available roles in the system
1544
func (s *UserService) GetAllRoles(ctx context.Context) (result0 []models.Role, err error) {
1545
    ctx, span := observability.TraceUserFunction(ctx, "get_all_roles")
1546
    defer observability.FinishSpan(span, &err)
1547

1548
    query := `
1549
        SELECT id, name, description, created_at, updated_at
1550
        FROM roles
1551
        ORDER BY name
1552
    `
1553
    rows, err := s.db.QueryContext(ctx, query)
1554
    if err != nil {
1555
        return nil, contextutils.WrapError(err, "failed to get all roles")
1556
    }
1557
    defer func() {
1558
        if closeErr := rows.Close(); closeErr != nil {
1559
            s.logger.Warn(ctx, "Warning: failed to close rows", map[string]interface{}{"error": closeErr.Error()})
1560
        }
1561
    }()
1562

1563
    var roles []models.Role
1564
    for rows.Next() {
1565
        var role models.Role
1566
        err := rows.Scan(&role.ID, &role.Name, &role.Description, &role.CreatedAt, &role.UpdatedAt)
1567
        if err != nil {
1568
            return nil, contextutils.WrapError(err, "failed to scan role")
1569
        }
1570
        roles = append(roles, role)
1571
    }
1572

1573
    if err = rows.Err(); err != nil {
1574
        return nil, contextutils.WrapError(err, "error iterating roles")
1575
    }
1576

1577
    return roles, nil
1578
}
1579

1580
// GetDB returns the database connection
1581
func (s *UserService) GetDB() *sql.DB {
1582
    return s.db
1583
}
1584

1585
// isDuplicateKeyError checks if the error is a duplicate key constraint violation
1586
func isDuplicateKeyError(err error) bool {
1587
    if err == nil {
1588
        return false
1589
    }
1590

1591
    // Check for PostgreSQL unique constraint violation error code
1592
    if pqErr, ok := err.(*pq.Error); ok {
1593
        // PostgreSQL error code 23505 is for unique constraint violations
1594
        if pqErr.Code == "23505" {
1595
            return true
1596
        }
1597
    }
1598

1599
    return false
1600
}
1601


			
quizapp internal services worker_service.go
83.2%
Statements
89/107
1
package services
2

3
import (
4
    "context"
5
    "math/rand"
6

7
    "go.opentelemetry.io/otel/attribute"
8

9
    "quizapp/internal/config"
10
    "quizapp/internal/observability"
11
)
12

13
// VarietyService handles the selection of variety elements for question generation
14
type VarietyService struct {
15
    cfg    *config.Config
16
    logger *observability.Logger
17
}
18

19
// VarietyElements holds the randomly selected variety elements for a question generation request
20
type VarietyElements struct {
21
    TopicCategory      string
22
    GrammarFocus       string
23
    VocabularyDomain   string
24
    Scenario           string
25
    StyleModifier      string
26
    DifficultyModifier string
27
    TimeContext        string
28
}
29

30
// NewVarietyServiceWithLogger creates a new VarietyService with logger
31
72x
func NewVarietyServiceWithLogger(cfg *config.Config, logger *observability.Logger) *VarietyService {
32
72x
    return &VarietyService{
33
72x
        cfg:    cfg,
34
72x
        logger: logger,
35
72x
    }
36
72x
}
37

38
// SelectVarietyElements randomly selects variety elements for question generation
39
// If highPriorityTopics or userWeakAreas are provided, bias topic selection toward those topics first, then gapAnalysis.
40
1277x
func (vs *VarietyService) SelectVarietyElements(ctx context.Context, level string, highPriorityTopics, userWeakAreas []string, gapAnalysis map[string]int) *VarietyElements {
41
1277x
    _, span := observability.TraceVarietyFunction(ctx, "select_variety_elements",
42
1277x
        attribute.String("variety.level", level),
43
1277x
        attribute.Int("variety.high_priority_topics_count", len(highPriorityTopics)),
44
1277x
        attribute.Int("variety.user_weak_areas_count", len(userWeakAreas)),
45
1277x
        attribute.Int("variety.gap_analysis_count", len(gapAnalysis)),
46
1277x
    )
47
1277x
    defer span.End()
48
1277x

49
1277x
    // Get variety configuration from config
50
1277x
    if vs.cfg.Variety != nil {
51
1276x
        variety := vs.cfg.Variety
52
1276x
        elements := &VarietyElements{}
53
1276x

54
1276x
        // Helper function to get weighted selection from gap analysis
55
1276x
        getWeightedSelection := func(gapType string, availableOptions []string) string {
56
3333x
            if len(gapAnalysis) == 0 || len(availableOptions) == 0 {
57
605x
                return ""
58
605x
            }
59

60
2728x
            var weightedOptions []string
61
2728x
            for _, option := range availableOptions {
62
8911x
                gapKey := gapType + "_" + option
63
8911x
                if count, ok := gapAnalysis[gapKey]; ok && count > 0 {
64
1510x
                    // Intensify weighting by squaring the severity to reduce randomness sensitivity
65
1510x
                    weight := count * count
66
1510x
                    for range weight {
67
8696x
                        weightedOptions = append(weightedOptions, option)
68
8696x
                    }
69
                }
70
            }
71

72
2728x
            if len(weightedOptions) > 0 {
73
1053x
                return weightedOptions[rand.Intn(len(weightedOptions))]
74
1053x
            }
75
1675x
            return ""
76
        }
77

78
        // Define all possible variety elements with their selection functions
79
1276x
        type varietySelector struct {
80
1276x
            name     string
81
1276x
            selector func() string
82
1276x
        }
83
1276x

84
1276x
        var selectors []varietySelector
85
1276x

86
1276x
        // Topic category selector (biased by userWeakAreas, highPriorityTopics, then gapAnalysis if provided)
87
1276x
        if len(variety.TopicCategories) > 0 {
88
1276x
            selectors = append(selectors, varietySelector{
89
1276x
                name: "topic_category",
90
1276x
                selector: func() string {
91
874x
                    // 1. UserWeakAreas
92
874x
                    if len(userWeakAreas) > 0 {
93
                        var matching []string
94
                        for _, topic := range variety.TopicCategories {
95
                            for _, weak := range userWeakAreas {
96
                                if topic == weak {
97
                                    matching = append(matching, topic)
98
                                }
99
                            }
100
                        }
101
                        if len(matching) > 0 {
102
                            elements.TopicCategory = matching[rand.Intn(len(matching))]
103
                            return elements.TopicCategory
104
                        }
105
                    }
106
                    // 2. HighPriorityTopics
107
874x
                    if len(highPriorityTopics) > 0 {
108
                        var matching []string
109
                        for _, topic := range variety.TopicCategories {
110
                            for _, high := range highPriorityTopics {
111
                                if topic == high {
112
                                    matching = append(matching, topic)
113
                                }
114
                            }
115
                        }
116
                        if len(matching) > 0 {
117
                            elements.TopicCategory = matching[rand.Intn(len(matching))]
118
                            return elements.TopicCategory
119
                        }
120
                    }
121
                    // 3. GapAnalysis for topics
122
874x
                    if selected := getWeightedSelection("topic_category", variety.TopicCategories); selected != "" {
123
404x
                        elements.TopicCategory = selected
124
404x
                        return elements.TopicCategory
125
404x
                    }
126
                    // Fallback to random
127
470x
                    elements.TopicCategory = variety.TopicCategories[rand.Intn(len(variety.TopicCategories))]
128
470x
                    return elements.TopicCategory
129
                },
130
            })
131
        }
132

133
        // Grammar focus selector (now with gap analysis support)
134
1276x
        if grammarByLevel, exists := variety.GrammarFocusByLevel[level]; exists && len(grammarByLevel) > 0 {
135
1275x
            selectors = append(selectors, varietySelector{
136
1275x
                name: "grammar_focus",
137
1275x
                selector: func() string {
138
822x
                    // Check for grammar gaps first
139
822x
                    if selected := getWeightedSelection("grammar_focus", grammarByLevel); selected != "" {
140
189x
                        elements.GrammarFocus = selected
141
189x
                        return elements.GrammarFocus
142
189x
                    }
143
                    // Fallback to random
144
633x
                    elements.GrammarFocus = grammarByLevel[rand.Intn(len(grammarByLevel))]
145
633x
                    return elements.GrammarFocus
146
                },
147
            })
148
1x
        } else if len(variety.GrammarFocus) > 0 {
149
1x
            selectors = append(selectors, varietySelector{
150
1x
                name: "grammar_focus",
151
1x
                selector: func() string {
152
1x
                    // Check for grammar gaps first
153
1x
                    if selected := getWeightedSelection("grammar_focus", variety.GrammarFocus); selected != "" {
154
                        elements.GrammarFocus = selected
155
                        return elements.GrammarFocus
156
                    }
157
                    // Fallback to random
158
1x
                    elements.GrammarFocus = variety.GrammarFocus[rand.Intn(len(variety.GrammarFocus))]
159
1x
                    return elements.GrammarFocus
160
                },
161
            })
162
        }
163

164
        // Vocabulary domain selector (now with gap analysis support)
165
1276x
        if len(variety.VocabularyDomains) > 0 {
166
1273x
            selectors = append(selectors, varietySelector{
167
1273x
                name: "vocabulary_domain",
168
1273x
                selector: func() string {
169
804x
                    // Check for vocabulary gaps first
170
804x
                    if selected := getWeightedSelection("vocabulary_domain", variety.VocabularyDomains); selected != "" {
171
101x
                        elements.VocabularyDomain = selected
172
101x
                        return elements.VocabularyDomain
173
101x
                    }
174
                    // Fallback to random
175
602x
                    elements.VocabularyDomain = variety.VocabularyDomains[rand.Intn(len(variety.VocabularyDomains))]
176
602x
                    return elements.VocabularyDomain
177
                },
178
            })
179
        }
180

181
        // Scenario selector (now with gap analysis support)
182
1276x
        if len(variety.Scenarios) > 0 {
183
1273x
            selectors = append(selectors, varietySelector{
184
1273x
                name: "scenario",
185
1273x
                selector: func() string {
186
832x
                    // Check for scenario gaps first
187
832x
                    if selected := getWeightedSelection("scenario", variety.Scenarios); selected != "" {
188
258x
                        elements.Scenario = selected
189
258x
                        return elements.Scenario
190
258x
                    }
191
                    // Fallback to random
192
574x
                    elements.Scenario = variety.Scenarios[rand.Intn(len(variety.Scenarios))]
193
574x
                    return elements.Scenario
194
                },
195
            })
196
        }
197

198
        // Style modifier selector
199
1276x
        if len(variety.StyleModifiers) > 0 {
200
1273x
            selectors = append(selectors, varietySelector{
201
1273x
                name: "style_modifier",
202
1273x
                selector: func() string {
203
834x
                    elements.StyleModifier = variety.StyleModifiers[rand.Intn(len(variety.StyleModifiers))]
204
834x
                    return elements.StyleModifier
205
834x
                },
206
            })
207
        }
208

209
        // Difficulty modifier selector
210
1276x
        if len(variety.DifficultyModifiers) > 0 {
211
1273x
            selectors = append(selectors, varietySelector{
212
1273x
                name: "difficulty_modifier",
213
1273x
                selector: func() string {
214
811x
                    elements.DifficultyModifier = variety.DifficultyModifiers[rand.Intn(len(variety.DifficultyModifiers))]
215
811x
                    return elements.DifficultyModifier
216
811x
                },
217
            })
218
        }
219

220
        // Time context selector
221
1276x
        if len(variety.TimeContexts) > 0 {
222
1273x
            selectors = append(selectors, varietySelector{
223
1273x
                name: "time_context",
224
1273x
                selector: func() string {
225
872x
                    elements.TimeContext = variety.TimeContexts[rand.Intn(len(variety.TimeContexts))]
226
872x
                    return elements.TimeContext
227
872x
                },
228
            })
229
        }
230

231
        // Randomly select 2-3 variety elements (instead of all 7)
232
1276x
        numToSelect := 2
233
1276x
        if len(selectors) > 2 {
234
1273x
            // 70% chance of 2 elements, 30% chance of 3 elements
235
1273x
            if rand.Float64() < 0.3 {
236
746x
                numToSelect = 3
237
746x
            }
238
        }
239

240
        // Shuffle and select the first numToSelect elements
241
1276x
        rand.Shuffle(len(selectors), func(i, j int) {
242
7641x
            selectors[i], selectors[j] = selectors[j], selectors[i]
243
7641x
        })
244

245
        // Apply the selected variety elements
246
1276x
        for i := 0; i < numToSelect && i < len(selectors); i++ {
247
5850x
            selected := selectors[i].selector()
248
5850x
            span.SetAttributes(attribute.String("variety."+selectors[i].name, selected))
249
5850x
        }
250

251
1276x
        span.SetAttributes(
252
1276x
            attribute.String("variety.topic_category", elements.TopicCategory),
253
1276x
            attribute.String("variety.grammar_focus", elements.GrammarFocus),
254
1276x
            attribute.String("variety.vocabulary_domain", elements.VocabularyDomain),
255
1276x
            attribute.String("variety.scenario", elements.Scenario),
256
1276x
            attribute.String("variety.style_modifier", elements.StyleModifier),
257
1276x
            attribute.String("variety.difficulty_modifier", elements.DifficultyModifier),
258
1276x
            attribute.String("variety.time_context", elements.TimeContext),
259
1276x
            attribute.Int("variety.elements_selected", numToSelect),
260
1276x
        )
261
1276x

262
1276x
        span.SetAttributes(attribute.String("variety.result", "success"))
263
1276x
        return elements
264
    }
265

266
1x
    span.SetAttributes(attribute.String("variety.result", "no_config"))
267
1x
    return &VarietyElements{} // Return empty if no variety config
268
}
269

270
// SelectMultipleVarietyElements selects multiple sets of variety elements for batch generation
271
1x
func (vs *VarietyService) SelectMultipleVarietyElements(ctx context.Context, level string, count int) []*VarietyElements {
272
1x
    ctx, span := observability.TraceVarietyFunction(ctx, "select_multiple_variety_elements",
273
1x
        attribute.String("variety.level", level),
274
1x
        attribute.Int("variety.count", count),
275
1x
    )
276
1x
    defer span.End()
277
1x

278
1x
    elements := make([]*VarietyElements, count)
279
1x
    for i := 0; i < count; i++ {
280
3x
        elements[i] = vs.SelectVarietyElements(ctx, level, nil, nil, nil)
281
3x
    }
282

283
1x
    span.SetAttributes(attribute.String("variety.result", "success"), attribute.Int("variety.elements_count", len(elements)))
284
1x
    return elements
285
}
286


			
quizapp internal services worker_service.go
43.7%
Statements
93/213
1
package services
2

3
import (
4
    "context"
5
    "database/sql"
6
    "encoding/json"
7
    "errors"
8
    "fmt"
9
    "math/rand"
10
    "time"
11

12
    "quizapp/internal/models"
13
    "quizapp/internal/observability"
14
    contextutils "quizapp/internal/utils"
15

16
    "go.opentelemetry.io/otel"
17
    "go.opentelemetry.io/otel/attribute"
18
    "go.opentelemetry.io/otel/codes"
19
    "go.opentelemetry.io/otel/trace"
20
)
21

22
// WordOfTheDayServiceInterface defines the interface for word of the day operations
23
type WordOfTheDayServiceInterface interface {
24
    GetWordOfTheDay(ctx context.Context, userID int, date time.Time) (*models.WordOfTheDayDisplay, error)
25
    SelectWordOfTheDay(ctx context.Context, userID int, date time.Time) (*models.WordOfTheDayDisplay, error)
26
    GetWordHistory(ctx context.Context, userID int, startDate, endDate time.Time) ([]*models.WordOfTheDayDisplay, error)
27
}
28

29
// WordOfTheDayService implements word of the day operations
30
type WordOfTheDayService struct {
31
    db     *sql.DB
32
    logger *observability.Logger
33
}
34

35
// ErrNoSuitableWord indicates there was no suitable word available for the user/date.
36
var ErrNoSuitableWord = errors.New("no suitable word found")
37

38
// NewWordOfTheDayService creates a new WordOfTheDayService instance
39
1x
func NewWordOfTheDayService(db *sql.DB, logger *observability.Logger) *WordOfTheDayService {
40
1x
    return &WordOfTheDayService{
41
1x
        db:     db,
42
1x
        logger: logger,
43
1x
    }
44
1x
}
45

46
// GetWordOfTheDay retrieves the word of the day for a user and date
47
// If not exists, it will generate one by calling SelectWordOfTheDay
48
2x
func (s *WordOfTheDayService) GetWordOfTheDay(ctx context.Context, userID int, date time.Time) (*models.WordOfTheDayDisplay, error) {
49
2x
    ctx, span := otel.Tracer("word-of-the-day-service").Start(ctx, "GetWordOfTheDay",
50
2x
        trace.WithAttributes(
51
2x
            attribute.Int("user.id", userID),
52
2x
            attribute.String("date", date.Format("2006-01-02")),
53
2x
        ),
54
2x
    )
55
2x
    defer observability.FinishSpan(span, nil)
56
2x

57
2x
    // Normalize date to just the date part (no time)
58
2x
    date = time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, time.UTC)
59
2x

60
2x
    // Try to get existing word of the day
61
2x
    // Attach username to span (best-effort)
62
2x
    if u, _ := s.getUserByID(ctx, userID); u != nil {
63
2x
        span.SetAttributes(attribute.String("user.username", u.Username))
64
2x
    }
65
2x
    word, err := s.getWordOfTheDayFromDB(ctx, userID, date)
66
2x
    if err != nil && err != sql.ErrNoRows {
67
        span.RecordError(err, trace.WithStackTrace(true))
68
        span.SetStatus(codes.Error, err.Error())
69
        return nil, contextutils.WrapError(err, "failed to get word of the day from database")
70
    }
71

72
    // If exists, return it
73
2x
    if word != nil {
74
1x
        span.SetAttributes(
75
1x
            attribute.String("source_type", string(word.SourceType)),
76
1x
            attribute.Int("source_id", word.SourceID),
77
1x
        )
78
1x
        return s.convertToDisplay(ctx, word)
79
1x
    }
80

81
    // If not exists, generate one
82
1x
    s.logger.Info(ctx, "Word of the day not found, generating new one", map[string]interface{}{
83
1x
        "user_id": userID,
84
1x
        "date":    date.Format("2006-01-02"),
85
1x
    })
86
1x

87
1x
    return s.SelectWordOfTheDay(ctx, userID, date)
88
}
89

90
// SelectWordOfTheDay selects and assigns a word of the day for a user and date
91
1x
func (s *WordOfTheDayService) SelectWordOfTheDay(ctx context.Context, userID int, date time.Time) (*models.WordOfTheDayDisplay, error) {
92
1x
    ctx, span := otel.Tracer("word-of-the-day-service").Start(ctx, "SelectWordOfTheDay",
93
1x
        trace.WithAttributes(
94
1x
            attribute.Int("user.id", userID),
95
1x
            attribute.String("date", date.Format("2006-01-02")),
96
1x
        ),
97
1x
    )
98
1x
    defer observability.FinishSpan(span, nil)
99
1x

100
1x
    // Normalize date to just the date part (no time)
101
1x
    date = time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, time.UTC)
102
1x

103
1x
    // Get user preferences
104
1x
    user, err := s.getUserByID(ctx, userID)
105
1x
    if err != nil {
106
        span.RecordError(err, trace.WithStackTrace(true))
107
        span.SetStatus(codes.Error, err.Error())
108
        return nil, contextutils.WrapError(err, "failed to get user")
109
    }
110

111
1x
    if user == nil {
112
        err := contextutils.ErrorWithContextf("user not found: %d", userID)
113
        span.RecordError(err, trace.WithStackTrace(true))
114
        span.SetStatus(codes.Error, err.Error())
115
        return nil, err
116
    }
117

118
1x
    language := user.PreferredLanguage.String
119
1x
    level := user.CurrentLevel.String
120
1x

121
1x
    if language == "" {
122
        err := contextutils.ErrorWithContextf("user missing language preference")
123
        span.RecordError(err, trace.WithStackTrace(true))
124
        span.SetStatus(codes.Error, err.Error())
125
        return nil, err
126
    }
127

128
1x
    span.SetAttributes(
129
1x
        attribute.String("language", language),
130
1x
        attribute.String("level", level),
131
1x
        attribute.String("user.username", user.Username),
132
1x
    )
133
1x

134
1x
    // Randomly decide between vocabulary question (70%) or snippet (30%)
135
1x
    useVocabulary := rand.Float32() < 0.7
136
1x

137
1x
    var word *models.WordOfTheDay
138
1x
    if useVocabulary {
139
1x
        word, err = s.selectVocabularyQuestion(ctx, userID, language, level, date)
140
1x
        if err != nil || word == nil {
141
            s.logger.Warn(ctx, "Failed to select vocabulary question, trying snippet instead", map[string]interface{}{
142
                "error": err,
143
            })
144
            // Fallback to snippet
145
            word, err = s.selectSnippet(ctx, userID, language, date)
146
        }
147
    } else {
148
        word, err = s.selectSnippet(ctx, userID, language, date)
149
        if err != nil || word == nil {
150
            s.logger.Warn(ctx, "Failed to select snippet, trying vocabulary question instead", map[string]interface{}{
151
                "error": err,
152
            })
153
            // Fallback to vocabulary question
154
            word, err = s.selectVocabularyQuestion(ctx, userID, language, level, date)
155
        }
156
    }
157

158
1x
    if err != nil {
159
        span.RecordError(err, trace.WithStackTrace(true))
160
        span.SetStatus(codes.Error, err.Error())
161
        return nil, contextutils.WrapError(err, "failed to select word of the day")
162
    }
163

164
1x
    if word == nil {
165
        // No available word is a normal condition: surface as a typed sentinel without error status
166
        span.SetAttributes(attribute.Bool("no_word_available", true))
167
        return nil, ErrNoSuitableWord
168
    }
169

170
    // Save to database
171
1x
    err = s.saveWordOfTheDay(ctx, word)
172
1x
    if err != nil {
173
        span.RecordError(err, trace.WithStackTrace(true))
174
        span.SetStatus(codes.Error, err.Error())
175
        return nil, contextutils.WrapError(err, "failed to save word of the day")
176
    }
177

178
1x
    span.SetAttributes(
179
1x
        attribute.String("source_type", string(word.SourceType)),
180
1x
        attribute.Int("source_id", word.SourceID),
181
1x
    )
182
1x

183
1x
    s.logger.Info(ctx, "Word of the day selected", map[string]interface{}{
184
1x
        "user_id":     userID,
185
1x
        "date":        date.Format("2006-01-02"),
186
1x
        "source_type": word.SourceType,
187
1x
        "source_id":   word.SourceID,
188
1x
    })
189
1x

190
1x
    return s.convertToDisplay(ctx, word)
191
}
192

193
// GetWordHistory retrieves word of the day history for a date range
194
func (s *WordOfTheDayService) GetWordHistory(ctx context.Context, userID int, startDate, endDate time.Time) ([]*models.WordOfTheDayDisplay, error) {
195
    ctx, span := otel.Tracer("word-of-the-day-service").Start(ctx, "GetWordHistory",
196
        trace.WithAttributes(
197
            attribute.Int("user.id", userID),
198
            attribute.String("start_date", startDate.Format("2006-01-02")),
199
            attribute.String("end_date", endDate.Format("2006-01-02")),
200
        ),
201
    )
202
    defer observability.FinishSpan(span, nil)
203

204
    if u, _ := s.getUserByID(ctx, userID); u != nil {
205
        span.SetAttributes(attribute.String("user.username", u.Username))
206
    }
207

208
    // Normalize dates
209
    startDate = time.Date(startDate.Year(), startDate.Month(), startDate.Day(), 0, 0, 0, 0, time.UTC)
210
    endDate = time.Date(endDate.Year(), endDate.Month(), endDate.Day(), 0, 0, 0, 0, time.UTC)
211

212
    query := `
213
        SELECT id, user_id, assignment_date, source_type, source_id, created_at
214
        FROM word_of_the_day
215
        WHERE user_id = $1 AND assignment_date >= $2 AND assignment_date <= $3
216
        ORDER BY assignment_date DESC
217
    `
218

219
    rows, err := s.db.QueryContext(ctx, query, userID, startDate, endDate)
220
    if err != nil {
221
        span.RecordError(err, trace.WithStackTrace(true))
222
        span.SetStatus(codes.Error, err.Error())
223
        return nil, contextutils.WrapError(err, "failed to query word history")
224
    }
225
    defer func() {
226
        if closeErr := rows.Close(); closeErr != nil {
227
            span.RecordError(closeErr, trace.WithStackTrace(true))
228
            s.logger.Warn(ctx, "Failed to close rows", map[string]interface{}{"error": closeErr.Error()})
229
        }
230
    }()
231

232
    var words []*models.WordOfTheDay
233
    for rows.Next() {
234
        var w models.WordOfTheDay
235
        err := rows.Scan(&w.ID, &w.UserID, &w.AssignmentDate, &w.SourceType, &w.SourceID, &w.CreatedAt)
236
        if err != nil {
237
            span.RecordError(err, trace.WithStackTrace(true))
238
            span.SetStatus(codes.Error, err.Error())
239
            return nil, contextutils.WrapError(err, "failed to scan word row")
240
        }
241
        words = append(words, &w)
242
    }
243

244
    if err = rows.Err(); err != nil {
245
        span.RecordError(err, trace.WithStackTrace(true))
246
        span.SetStatus(codes.Error, err.Error())
247
        return nil, contextutils.WrapError(err, "error iterating word rows")
248
    }
249

250
    // Convert to display format
251
    var displays []*models.WordOfTheDayDisplay
252
    for _, w := range words {
253
        display, err := s.convertToDisplay(ctx, w)
254
        if err != nil {
255
            s.logger.Error(ctx, "Failed to convert word to display", err, map[string]interface{}{
256
                "word_id":     w.ID,
257
                "source_type": w.SourceType,
258
                "source_id":   w.SourceID,
259
            })
260
            continue
261
        }
262
        displays = append(displays, display)
263
    }
264

265
    span.SetAttributes(attribute.Int("count", len(displays)))
266

267
    return displays, nil
268
}
269

270
// selectVocabularyQuestion selects a vocabulary question for word of the day
271
1x
func (s *WordOfTheDayService) selectVocabularyQuestion(ctx context.Context, userID int, language, level string, date time.Time) (*models.WordOfTheDay, error) {
272
1x
    ctx, span := otel.Tracer("word-of-the-day-service").Start(ctx, "selectVocabularyQuestion",
273
1x
        trace.WithAttributes(
274
1x
            attribute.Int("user.id", userID),
275
1x
            attribute.String("language", language),
276
1x
            attribute.String("level", level),
277
1x
        ),
278
1x
    )
279
1x
    defer observability.FinishSpan(span, nil)
280
1x

281
1x
    if u, _ := s.getUserByID(ctx, userID); u != nil {
282
1x
        span.SetAttributes(attribute.String("user.username", u.Username))
283
1x
    }
284

285
    // Query for vocabulary questions that haven't been used as word of the day recently
286
1x
    query := `
287
1x
        SELECT q.id
288
1x
        FROM questions q
289
1x
        WHERE q.type = 'vocabulary'
290
1x
          AND q.language = $1
291
1x
          AND q.status = 'active'
292
1x
          AND ($2 = '' OR q.level = $2)
293
1x
          AND NOT EXISTS (
294
1x
            SELECT 1 FROM word_of_the_day wotd
295
1x
            WHERE wotd.user_id = $3
296
1x
              AND wotd.source_type = 'vocabulary_question'
297
1x
              AND wotd.source_id = q.id
298
1x
              AND wotd.assignment_date > $4
299
1x
          )
300
1x
        ORDER BY RANDOM()
301
1x
        LIMIT 1
302
1x
    `
303
1x

304
1x
    // Don't reuse words from the last 60 days
305
1x
    cutoffDate := date.AddDate(0, 0, -60)
306
1x

307
1x
    var questionID int
308
1x
    err := s.db.QueryRowContext(ctx, query, language, level, userID, cutoffDate).Scan(&questionID)
309
1x
    if err == sql.ErrNoRows {
310
        // Try without the recency check
311
        queryNoRecency := `
312
            SELECT q.id
313
            FROM questions q
314
            WHERE q.type = 'vocabulary'
315
              AND q.language = $1
316
              AND q.status = 'active'
317
              AND ($2 = '' OR q.level = $2)
318
            ORDER BY RANDOM()
319
            LIMIT 1
320
        `
321
        err = s.db.QueryRowContext(ctx, queryNoRecency, language, level).Scan(&questionID)
322
    }
323

324
1x
    if err != nil {
325
        if err == sql.ErrNoRows {
326
            return nil, nil // No vocabulary questions available
327
        }
328
        span.RecordError(err, trace.WithStackTrace(true))
329
        span.SetStatus(codes.Error, err.Error())
330
        return nil, contextutils.WrapError(err, "failed to query vocabulary question")
331
    }
332

333
1x
    return &models.WordOfTheDay{
334
1x
        UserID:         userID,
335
1x
        AssignmentDate: date,
336
1x
        SourceType:     models.WordSourceVocabularyQuestion,
337
1x
        SourceID:       questionID,
338
1x
    }, nil
339
}
340

341
// selectSnippet selects a user snippet for word of the day
342
func (s *WordOfTheDayService) selectSnippet(ctx context.Context, userID int, language string, date time.Time) (*models.WordOfTheDay, error) {
343
    ctx, span := otel.Tracer("word-of-the-day-service").Start(ctx, "selectSnippet",
344
        trace.WithAttributes(
345
            attribute.Int("user.id", userID),
346
            attribute.String("language", language),
347
        ),
348
    )
349
    defer observability.FinishSpan(span, nil)
350

351
    if u, _ := s.getUserByID(ctx, userID); u != nil {
352
        span.SetAttributes(attribute.String("user.username", u.Username))
353
    }
354

355
    // Query for user's snippets that haven't been used as word of the day recently
356
    // Prefer more recent snippets (created in last 30 days)
357
    query := `
358
        SELECT s.id
359
        FROM snippets s
360
        WHERE s.user_id = $1
361
          AND s.source_language = $2
362
          AND NOT EXISTS (
363
            SELECT 1 FROM word_of_the_day wotd
364
            WHERE wotd.user_id = $1
365
              AND wotd.source_type = 'snippet'
366
              AND wotd.source_id = s.id
367
              AND wotd.assignment_date > $3
368
          )
369
        ORDER BY
370
          CASE WHEN s.created_at > $4 THEN 0 ELSE 1 END,
371
          RANDOM()
372
        LIMIT 1
373
    `
374

375
    // Don't reuse snippets from the last 60 days
376
    cutoffDate := date.AddDate(0, 0, -60)
377
    // Prefer snippets from the last 30 days
378
    recentCutoff := date.AddDate(0, 0, -30)
379

380
    var snippetID int
381
    err := s.db.QueryRowContext(ctx, query, userID, language, cutoffDate, recentCutoff).Scan(&snippetID)
382
    if err == sql.ErrNoRows {
383
        // Try without the recency check
384
        queryNoRecency := `
385
            SELECT s.id
386
            FROM snippets s
387
            WHERE s.user_id = $1
388
              AND s.source_language = $2
389
            ORDER BY RANDOM()
390
            LIMIT 1
391
        `
392
        err = s.db.QueryRowContext(ctx, queryNoRecency, userID, language).Scan(&snippetID)
393
    }
394

395
    if err != nil {
396
        if err == sql.ErrNoRows {
397
            return nil, nil // No snippets available
398
        }
399
        span.RecordError(err, trace.WithStackTrace(true))
400
        span.SetStatus(codes.Error, err.Error())
401
        return nil, contextutils.WrapError(err, "failed to query snippet")
402
    }
403

404
    return &models.WordOfTheDay{
405
        UserID:         userID,
406
        AssignmentDate: date,
407
        SourceType:     models.WordSourceSnippet,
408
        SourceID:       snippetID,
409
    }, nil
410
}
411

412
// getWordOfTheDayFromDB retrieves a word of the day from the database
413
2x
func (s *WordOfTheDayService) getWordOfTheDayFromDB(ctx context.Context, userID int, date time.Time) (*models.WordOfTheDay, error) {
414
2x
    query := `
415
2x
        SELECT id, user_id, assignment_date, source_type, source_id, created_at
416
2x
        FROM word_of_the_day
417
2x
        WHERE user_id = $1 AND assignment_date = $2
418
2x
    `
419
2x

420
2x
    var w models.WordOfTheDay
421
2x
    err := s.db.QueryRowContext(ctx, query, userID, date).Scan(
422
2x
        &w.ID, &w.UserID, &w.AssignmentDate, &w.SourceType, &w.SourceID, &w.CreatedAt,
423
2x
    )
424
2x

425
2x
    if err == sql.ErrNoRows {
426
1x
        return nil, sql.ErrNoRows
427
1x
    }
428

429
1x
    if err != nil {
430
        return nil, contextutils.WrapError(err, "failed to query word of the day")
431
    }
432

433
1x
    return &w, nil
434
}
435

436
// saveWordOfTheDay saves a word of the day to the database
437
1x
func (s *WordOfTheDayService) saveWordOfTheDay(ctx context.Context, word *models.WordOfTheDay) error {
438
1x
    query := `
439
1x
        INSERT INTO word_of_the_day (user_id, assignment_date, source_type, source_id, created_at)
440
1x
        VALUES ($1, $2, $3, $4, $5)
441
1x
        ON CONFLICT (user_id, assignment_date) DO NOTHING
442
1x
        RETURNING id
443
1x
    `
444
1x

445
1x
    err := s.db.QueryRowContext(ctx, query,
446
1x
        word.UserID,
447
1x
        word.AssignmentDate,
448
1x
        word.SourceType,
449
1x
        word.SourceID,
450
1x
        time.Now(),
451
1x
    ).Scan(&word.ID)
452
1x
    if err != nil {
453
        return contextutils.WrapError(err, "failed to insert word of the day")
454
    }
455

456
1x
    return nil
457
}
458

459
// convertToDisplay converts a WordOfTheDay to WordOfTheDayDisplay format
460
2x
func (s *WordOfTheDayService) convertToDisplay(ctx context.Context, word *models.WordOfTheDay) (*models.WordOfTheDayDisplay, error) {
461
2x
    ctx, span := otel.Tracer("word-of-the-day-service").Start(ctx, "convertToDisplay")
462
2x
    defer observability.FinishSpan(span, nil)
463
2x

464
2x
    if u, _ := s.getUserByID(ctx, word.UserID); u != nil {
465
2x
        span.SetAttributes(
466
2x
            attribute.Int("user.id", u.ID),
467
2x
            attribute.String("user.username", u.Username),
468
2x
        )
469
2x
    }
470

471
2x
    display := &models.WordOfTheDayDisplay{
472
2x
        Date:       word.AssignmentDate,
473
2x
        SourceType: word.SourceType,
474
2x
        SourceID:   word.SourceID,
475
2x
    }
476
2x

477
2x
    switch word.SourceType {
478
2x
    case models.WordSourceVocabularyQuestion:
479
2x
        question, err := s.getQuestionByID(ctx, word.SourceID)
480
2x
        if err != nil {
481
            span.RecordError(err, trace.WithStackTrace(true))
482
            span.SetStatus(codes.Error, err.Error())
483
            return nil, contextutils.WrapError(err, "failed to get question")
484
        }
485

486
        // Extract word, translation, and sentence from question content
487
2x
        content := question.Content
488
2x
        if sentenceRaw, ok := content["sentence"]; ok {
489
2x
            display.Sentence = fmt.Sprintf("%v", sentenceRaw)
490
2x
        }
491
2x
        if questionRaw, ok := content["question"]; ok {
492
2x
            display.Word = fmt.Sprintf("%v", questionRaw)
493
2x
        }
494
2x
        if optionsRaw, ok := content["options"]; ok {
495
2x
            if options, ok := optionsRaw.([]interface{}); ok && len(options) > question.CorrectAnswer {
496
2x
                display.Translation = fmt.Sprintf("%v", options[question.CorrectAnswer])
497
2x
            }
498
        }
499

500
2x
        display.Language = question.Language
501
2x
        display.Level = question.Level
502
2x
        display.Explanation = question.Explanation
503
2x
        display.TopicCategory = question.TopicCategory
504

505
    case models.WordSourceSnippet:
506
        snippet, err := s.getSnippetByID(ctx, word.SourceID)
507
        if err != nil {
508
            span.RecordError(err, trace.WithStackTrace(true))
509
            span.SetStatus(codes.Error, err.Error())
510
            return nil, contextutils.WrapError(err, "failed to get snippet")
511
        }
512

513
        display.Word = snippet.OriginalText
514
        display.Translation = snippet.TranslatedText
515
        display.Language = snippet.SourceLanguage
516
        if snippet.Context != nil {
517
            display.Context = *snippet.Context
518
            display.Sentence = *snippet.Context
519
        }
520
        if snippet.DifficultyLevel != nil {
521
            display.Level = *snippet.DifficultyLevel
522
        }
523
    }
524

525
2x
    return display, nil
526
}
527

528
// getUserByID retrieves a user by ID
529
6x
func (s *WordOfTheDayService) getUserByID(ctx context.Context, userID int) (*models.User, error) {
530
6x
    query := `
531
6x
        SELECT id, username, email, preferred_language, current_level, timezone
532
6x
        FROM users
533
6x
        WHERE id = $1
534
6x
    `
535
6x

536
6x
    var user models.User
537
6x
    err := s.db.QueryRowContext(ctx, query, userID).Scan(
538
6x
        &user.ID,
539
6x
        &user.Username,
540
6x
        &user.Email,
541
6x
        &user.PreferredLanguage,
542
6x
        &user.CurrentLevel,
543
6x
        &user.Timezone,
544
6x
    )
545
6x

546
6x
    if err == sql.ErrNoRows {
547
        return nil, nil
548
    }
549

550
6x
    if err != nil {
551
        return nil, contextutils.WrapError(err, "failed to query user")
552
    }
553

554
6x
    return &user, nil
555
}
556

557
// getQuestionByID retrieves a question by ID
558
2x
func (s *WordOfTheDayService) getQuestionByID(ctx context.Context, questionID int) (*models.Question, error) {
559
2x
    query := `
560
2x
        SELECT id, type, language, level, difficulty_score, content, correct_answer,
561
2x
               explanation, created_at, status, topic_category, grammar_focus,
562
2x
               vocabulary_domain, scenario, style_modifier, difficulty_modifier, time_context
563
2x
        FROM questions
564
2x
        WHERE id = $1
565
2x
    `
566
2x

567
2x
    var question models.Question
568
2x
    var contentJSON []byte
569
2x

570
2x
    err := s.db.QueryRowContext(ctx, query, questionID).Scan(
571
2x
        &question.ID,
572
2x
        &question.Type,
573
2x
        &question.Language,
574
2x
        &question.Level,
575
2x
        &question.DifficultyScore,
576
2x
        &contentJSON,
577
2x
        &question.CorrectAnswer,
578
2x
        &question.Explanation,
579
2x
        &question.CreatedAt,
580
2x
        &question.Status,
581
2x
        &question.TopicCategory,
582
2x
        &question.GrammarFocus,
583
2x
        &question.VocabularyDomain,
584
2x
        &question.Scenario,
585
2x
        &question.StyleModifier,
586
2x
        &question.DifficultyModifier,
587
2x
        &question.TimeContext,
588
2x
    )
589
2x
    if err != nil {
590
        return nil, contextutils.WrapError(err, "failed to query question")
591
    }
592

593
    // Parse JSON content
594
2x
    content := make(map[string]interface{})
595
2x
    if err := json.Unmarshal(contentJSON, &content); err != nil {
596
        return nil, contextutils.WrapError(err, "failed to parse question content")
597
    }
598
2x
    question.Content = content
599
2x

600
2x
    return &question, nil
601
}
602

603
// getSnippetByID retrieves a snippet by ID
604
func (s *WordOfTheDayService) getSnippetByID(ctx context.Context, snippetID int) (*models.Snippet, error) {
605
    query := `
606
        SELECT id, user_id, original_text, translated_text, source_language,
607
               target_language, question_id, section_id, story_id, context,
608
               difficulty_level, created_at, updated_at
609
        FROM snippets
610
        WHERE id = $1
611
    `
612

613
    var snippet models.Snippet
614
    err := s.db.QueryRowContext(ctx, query, snippetID).Scan(
615
        &snippet.ID,
616
        &snippet.UserID,
617
        &snippet.OriginalText,
618
        &snippet.TranslatedText,
619
        &snippet.SourceLanguage,
620
        &snippet.TargetLanguage,
621
        &snippet.QuestionID,
622
        &snippet.SectionID,
623
        &snippet.StoryID,
624
        &snippet.Context,
625
        &snippet.DifficultyLevel,
626
        &snippet.CreatedAt,
627
        &snippet.UpdatedAt,
628
    )
629
    if err != nil {
630
        return nil, contextutils.WrapError(err, "failed to query snippet")
631
    }
632

633
    return &snippet, nil
634
}
635


			
quizapp internal services worker_service.go
30.3%
Statements
195/644
1
package services
2

3
import (
4
    "context"
5
    "database/sql"
6
    "errors"
7
    "fmt"
8
    "strings"
9
    "time"
10

11
    "quizapp/internal/models"
12
    "quizapp/internal/observability"
13
    contextutils "quizapp/internal/utils"
14

15
    "go.opentelemetry.io/otel/attribute"
16
)
17

18
// ErrSettingNotFound is returned when a setting is not found in the database
19
var ErrSettingNotFound = errors.New("setting not found")
20

21
// WorkerServiceInterface defines the interface for worker management operations
22
type WorkerServiceInterface interface {
23
    // Settings management
24
    GetSetting(ctx context.Context, key string) (string, error)
25
    SetSetting(ctx context.Context, key, value string) error
26
    IsGlobalPaused(ctx context.Context) (bool, error)
27
    SetGlobalPause(ctx context.Context, paused bool) error
28
    IsUserPaused(ctx context.Context, userID int) (bool, error)
29
    SetUserPause(ctx context.Context, userID int, paused bool) error
30

31
    // Status management
32
    UpdateWorkerStatus(ctx context.Context, instance string, status *models.WorkerStatus) error
33
    GetWorkerStatus(ctx context.Context, instance string) (*models.WorkerStatus, error)
34
    GetAllWorkerStatuses(ctx context.Context) ([]models.WorkerStatus, error)
35
    UpdateHeartbeat(ctx context.Context, instance string) error
36
    IsWorkerHealthy(ctx context.Context, instance string) (bool, error)
37

38
    // Control operations
39
    PauseWorker(ctx context.Context, instance string) error
40
    ResumeWorker(ctx context.Context, instance string) error
41
    GetWorkerHealth(ctx context.Context) (map[string]interface{}, error)
42
    GetHighPriorityTopics(ctx context.Context, userID int, language, level, questionType string) ([]string, error)
43
    GetGapAnalysis(ctx context.Context, userID int, language, level, questionType string) (map[string]int, error)
44
    GetPriorityDistribution(ctx context.Context, userID int, language, level, questionType string) (map[string]int, error)
45

46
    // Notification management
47
    GetNotificationStats(ctx context.Context) (map[string]interface{}, error)
48
    GetNotificationErrors(ctx context.Context, page, pageSize int, errorType, notificationType, resolved string) ([]map[string]interface{}, map[string]interface{}, map[string]interface{}, error)
49
    GetUpcomingNotifications(ctx context.Context, page, pageSize int, notificationType, status, scheduledAfter, scheduledBefore string) ([]map[string]interface{}, map[string]interface{}, map[string]interface{}, error)
50
    GetSentNotifications(ctx context.Context, page, pageSize int, notificationType, status, sentAfter, sentBefore string) ([]map[string]interface{}, map[string]interface{}, map[string]interface{}, error)
51

52
    // Test methods for creating test data
53
    CreateTestSentNotification(ctx context.Context, userID int, notificationType, subject, templateName, status, errorMessage string) error
54
}
55

56
// WorkerService implements worker management operations
57
type WorkerService struct {
58
    db     *sql.DB
59
    logger *observability.Logger
60
}
61

62
// NewWorkerServiceWithLogger creates a new WorkerService instance with logger
63
14x
func NewWorkerServiceWithLogger(db *sql.DB, logger *observability.Logger) *WorkerService {
64
14x
    return &WorkerService{
65
14x
        db:     db,
66
14x
        logger: logger,
67
14x
    }
68
14x
}
69

70
// GetSetting retrieves a setting value by key
71
10x
func (s *WorkerService) GetSetting(ctx context.Context, key string) (result0 string, err error) {
72
10x
    ctx, span := observability.TraceWorkerFunction(ctx, "get_setting", attribute.String("setting.key", key))
73
10x
    defer observability.FinishSpan(span, &err)
74
10x

75
10x
    // Validate key
76
10x
    if len(key) == 0 || len(strings.TrimSpace(key)) == 0 {
77
1x
        return "", contextutils.WrapErrorf(errors.New("invalid setting key"), "setting key cannot be empty")
78
1x
    }
79

80
9x
    var value string
81
9x
    err = s.db.QueryRowContext(ctx, `
82
9x
        SELECT setting_value FROM worker_settings WHERE setting_key = $1
83
9x
    `, key).Scan(&value)
84
9x
    if err != nil {
85
3x
        if err == sql.ErrNoRows {
86
2x
            s.logger.Debug(ctx, "Setting not found", map[string]interface{}{"setting_key": key})
87
2x
            return "", contextutils.WrapErrorf(ErrSettingNotFound, "%s", key)
88
2x
        }
89
1x
        s.logger.Error(ctx, "Failed to get setting", err, map[string]interface{}{"setting_key": key})
90
1x
        return "", contextutils.WrapErrorf(err, "failed to get setting %s", key)
91
    }
92

93
6x
    return value, nil
94
}
95

96
// SetSetting updates or creates a setting
97
10x
func (s *WorkerService) SetSetting(ctx context.Context, key, value string) (err error) {
98
10x
    ctx, span := observability.TraceWorkerFunction(ctx, "set_setting", attribute.String("setting.key", key))
99
10x
    defer observability.FinishSpan(span, &err)
100
10x

101
10x
    // Validate key
102
10x
    if len(key) == 0 || len(strings.TrimSpace(key)) == 0 {
103
1x
        return contextutils.WrapErrorf(errors.New("invalid setting key"), "setting key cannot be empty")
104
1x
    }
105

106
9x
    _, err = s.db.ExecContext(ctx, `
107
9x
        INSERT INTO worker_settings (setting_key, setting_value, updated_at)
108
9x
        VALUES ($1, $2, NOW())
109
9x
        ON CONFLICT (setting_key) DO UPDATE SET
110
9x
            setting_value = EXCLUDED.setting_value,
111
9x
            updated_at = EXCLUDED.updated_at
112
9x
    `, key, value)
113
9x
    if err != nil {
114
2x
        s.logger.Error(ctx, "Failed to set setting", err, map[string]interface{}{"setting_key": key, "setting_value": value})
115
2x
        return contextutils.WrapErrorf(err, "failed to set setting %s", key)
116
2x
    }
117

118
7x
    s.logger.Debug(ctx, "Setting updated", map[string]interface{}{"setting_key": key, "setting_value": value})
119
7x
    return nil
120
}
121

122
// IsGlobalPaused checks if the worker is globally paused
123
4x
func (s *WorkerService) IsGlobalPaused(ctx context.Context) (result0 bool, err error) {
124
4x
    ctx, span := observability.TraceWorkerFunction(ctx, "is_global_paused")
125
4x
    defer observability.FinishSpan(span, &err)
126
4x

127
4x
    var value string
128
4x
    value, err = s.GetSetting(ctx, "global_pause")
129
4x
    if err != nil {
130
        // If setting doesn't exist, default to false (not paused)
131
        if errors.Is(err, ErrSettingNotFound) {
132
            // Initialize the setting with default value
133
            if setErr := s.SetSetting(ctx, "global_pause", "false"); setErr != nil {
134
                s.logger.Error(ctx, "Failed to initialize global_pause setting", setErr, map[string]interface{}{})
135
                return false, contextutils.WrapError(setErr, "failed to initialize global_pause setting")
136
            }
137
            return false, nil
138
        }
139
        s.logger.Error(ctx, "Failed to check global pause status", err, map[string]interface{}{})
140
        return false, err
141
    }
142

143
4x
    paused := value == "true"
144
4x
    s.logger.Debug(ctx, "Global pause status checked", map[string]interface{}{"global_paused": paused})
145
4x
    return paused, nil
146
}
147

148
// SetGlobalPause sets the global pause state
149
3x
func (s *WorkerService) SetGlobalPause(ctx context.Context, paused bool) (err error) {
150
3x
    ctx, span := observability.TraceWorkerFunction(ctx, "set_global_pause", attribute.Bool("paused", paused))
151
3x
    defer observability.FinishSpan(span, &err)
152
3x

153
3x
    value := "false"
154
3x
    if paused {
155
2x
        value = "true"
156
2x
    }
157

158
3x
    err = s.SetSetting(ctx, "global_pause", value)
159
3x
    if err != nil {
160
1x
        return err
161
1x
    }
162

163
2x
    s.logger.Info(ctx, "Global pause state updated", map[string]interface{}{"global_paused": paused})
164
2x
    return nil
165
}
166

167
// IsUserPaused checks if a specific user is paused
168
5x
func (s *WorkerService) IsUserPaused(ctx context.Context, userID int) (result0 bool, err error) {
169
5x
    ctx, span := observability.TraceWorkerFunction(ctx, "is_user_paused", observability.AttributeUserID(userID))
170
5x
    defer observability.FinishSpan(span, &err)
171
5x

172
5x
    key := fmt.Sprintf("user_pause_%d", userID)
173
5x
    var value string
174
5x
    err = s.db.QueryRowContext(ctx, `
175
5x
        SELECT setting_value FROM worker_settings WHERE setting_key = $1
176
5x
    `, key).Scan(&value)
177
5x
    if err != nil {
178
2x
        if err == sql.ErrNoRows {
179
2x
            // If setting doesn't exist, user is not paused (this is the default state)
180
2x
            s.logger.Debug(ctx, "User pause setting not found, defaulting to not paused", map[string]interface{}{"user_id": userID})
181
2x
            return false, nil
182
2x
        }
183
        s.logger.Error(ctx, "Failed to check user pause status", err, map[string]interface{}{"user_id": userID})
184
        return false, contextutils.WrapErrorf(err, "failed to check user pause status for user %d", userID)
185
    }
186

187
3x
    paused := value == "true"
188
3x
    s.logger.Debug(ctx, "User pause status checked", map[string]interface{}{"user_id": userID, "user_paused": paused})
189
3x
    return paused, nil
190
}
191

192
// SetUserPause sets the pause state for a specific user
193
3x
func (s *WorkerService) SetUserPause(ctx context.Context, userID int, paused bool) (err error) {
194
3x
    ctx, span := observability.TraceWorkerFunction(ctx, "set_user_pause", observability.AttributeUserID(userID), attribute.Bool("paused", paused))
195
3x
    defer observability.FinishSpan(span, &err)
196
3x

197
3x
    key := fmt.Sprintf("user_pause_%d", userID)
198
3x
    value := "false"
199
3x
    if paused {
200
2x
        value = "true"
201
2x
    }
202

203
3x
    err = s.SetSetting(ctx, key, value)
204
3x
    if err != nil {
205
        return err
206
    }
207

208
3x
    s.logger.Info(ctx, "User pause state updated", map[string]interface{}{"user_id": userID, "user_paused": paused})
209
3x
    return nil
210
}
211

212
// UpdateWorkerStatus updates the worker status in the database
213
10x
func (s *WorkerService) UpdateWorkerStatus(ctx context.Context, instance string, status *models.WorkerStatus) (err error) {
214
10x
    activity := ""
215
10x
    if status.CurrentActivity.Valid {
216
2x
        activity = status.CurrentActivity.String
217
2x
    }
218

219
10x
    ctx, span := observability.TraceWorkerFunction(ctx, "update_worker_status",
220
10x
        attribute.String("worker.instance", instance),
221
10x
        attribute.Bool("worker.is_running", status.IsRunning),
222
10x
        attribute.Bool("worker.is_paused", status.IsPaused),
223
10x
        attribute.String("worker.activity", activity),
224
10x
    )
225
10x
    defer observability.FinishSpan(span, &err)
226
10x

227
10x
    _, err = s.db.ExecContext(ctx, `
228
10x
        INSERT INTO worker_status (
229
10x
            worker_instance, is_running, is_paused, current_activity,
230
10x
            last_heartbeat, last_run_start, last_run_finish, last_run_error,
231
10x
            total_questions_generated, total_runs, updated_at
232
10x
        ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, NOW())
233
10x
        ON CONFLICT (worker_instance) DO UPDATE SET
234
10x
            is_running = EXCLUDED.is_running,
235
10x
            is_paused = EXCLUDED.is_paused,
236
10x
            current_activity = EXCLUDED.current_activity,
237
10x
            last_heartbeat = EXCLUDED.last_heartbeat,
238
10x
            last_run_start = EXCLUDED.last_run_start,
239
10x
            last_run_finish = EXCLUDED.last_run_finish,
240
10x
            last_run_error = EXCLUDED.last_run_error,
241
10x
            total_questions_generated = EXCLUDED.total_questions_generated,
242
10x
            total_runs = EXCLUDED.total_runs,
243
10x
            updated_at = EXCLUDED.updated_at
244
10x
    `, instance, status.IsRunning, status.IsPaused, status.CurrentActivity,
245
10x
        status.LastHeartbeat, status.LastRunStart, status.LastRunFinish,
246
10x
        status.LastRunError, status.TotalQuestionsGenerated, status.TotalRuns)
247
10x
    if err != nil {
248
        s.logger.Error(ctx, "Failed to update worker status", err, map[string]interface{}{
249
            "worker_instance": instance,
250
            "is_running":      status.IsRunning,
251
            "is_paused":       status.IsPaused,
252
            "activity":        activity,
253
        })
254
        err = contextutils.WrapErrorf(err, "failed to update worker status for instance %s", instance)
255
        return err
256
    }
257

258
10x
    s.logger.Debug(ctx, "Worker status updated", map[string]interface{}{
259
10x
        "worker_instance": instance,
260
10x
        "is_running":      status.IsRunning,
261
10x
        "is_paused":       status.IsPaused,
262
10x
        "activity":        activity,
263
10x
    })
264
10x
    return nil
265
}
266

267
// GetWorkerStatus retrieves worker status by instance
268
6x
func (s *WorkerService) GetWorkerStatus(ctx context.Context, instance string) (result0 *models.WorkerStatus, err error) {
269
6x
    ctx, span := observability.TraceWorkerFunction(ctx, "get_worker_status", attribute.String("worker.instance", instance))
270
6x
    defer observability.FinishSpan(span, &err)
271
6x

272
6x
    var status models.WorkerStatus
273
6x
    err = s.db.QueryRowContext(ctx, `
274
6x
        SELECT id, worker_instance, is_running, is_paused, current_activity,
275
6x
               last_heartbeat, last_run_start, last_run_finish, last_run_error,
276
6x
               total_questions_generated, total_runs, created_at, updated_at
277
6x
        FROM worker_status WHERE worker_instance = $1
278
6x
    `, instance).Scan(
279
6x
        &status.ID, &status.WorkerInstance, &status.IsRunning, &status.IsPaused,
280
6x
        &status.CurrentActivity, &status.LastHeartbeat, &status.LastRunStart,
281
6x
        &status.LastRunFinish, &status.LastRunError, &status.TotalQuestionsGenerated,
282
6x
        &status.TotalRuns, &status.CreatedAt, &status.UpdatedAt,
283
6x
    )
284
6x
    if err != nil {
285
1x
        if err == sql.ErrNoRows {
286
1x
            s.logger.Debug(ctx, "Worker status not found", map[string]interface{}{"worker_instance": instance})
287
1x
            return nil, contextutils.WrapErrorf(err, "worker status not found for instance %s", instance)
288
1x
        }
289
        s.logger.Error(ctx, "Failed to get worker status", err, map[string]interface{}{"worker_instance": instance})
290
        return nil, contextutils.WrapErrorf(err, "failed to get worker status for instance %s", instance)
291
    }
292

293
5x
    return &status, nil
294
}
295

296
// GetAllWorkerStatuses retrieves all worker statuses
297
2x
func (s *WorkerService) GetAllWorkerStatuses(ctx context.Context) (result0 []models.WorkerStatus, err error) {
298
2x
    ctx, span := observability.TraceWorkerFunction(ctx, "get_all_worker_statuses")
299
2x
    defer observability.FinishSpan(span, &err)
300
2x

301
2x
    var rows *sql.Rows
302
2x
    rows, err = s.db.QueryContext(ctx, `
303
2x
        SELECT id, worker_instance, is_running, is_paused, current_activity,
304
2x
               last_heartbeat, last_run_start, last_run_finish, last_run_error,
305
2x
               total_questions_generated, total_runs, created_at, updated_at
306
2x
        FROM worker_status ORDER BY worker_instance
307
2x
    `)
308
2x
    if err != nil {
309
        s.logger.Error(ctx, "Failed to get all worker statuses", err, map[string]interface{}{})
310
        return nil, contextutils.WrapError(err, "failed to get all worker statuses")
311
    }
312
2x
    defer func() {
313
2x
        if err := rows.Close(); err != nil {
314
            s.logger.Error(ctx, "Failed to close rows", err, map[string]interface{}{})
315
        }
316
    }()
317

318
2x
    var statuses []models.WorkerStatus
319
2x
    for rows.Next() {
320
5x
        var status models.WorkerStatus
321
5x
        err = rows.Scan(
322
5x
            &status.ID, &status.WorkerInstance, &status.IsRunning, &status.IsPaused,
323
5x
            &status.CurrentActivity, &status.LastHeartbeat, &status.LastRunStart,
324
5x
            &status.LastRunFinish, &status.LastRunError, &status.TotalQuestionsGenerated,
325
5x
            &status.TotalRuns, &status.CreatedAt, &status.UpdatedAt,
326
5x
        )
327
5x
        if err != nil {
328
            s.logger.Error(ctx, "Failed to scan worker status row", err, map[string]interface{}{})
329
            return nil, contextutils.WrapError(err, "failed to scan worker status row")
330
        }
331
5x
        statuses = append(statuses, status)
332
    }
333

334
2x
    if err := rows.Err(); err != nil {
335
        s.logger.Error(ctx, "Error iterating worker status rows", err, map[string]interface{}{})
336
        return nil, contextutils.WrapError(err, "error iterating worker status rows")
337
    }
338

339
2x
    s.logger.Debug(ctx, "Retrieved all worker statuses", map[string]interface{}{"count": len(statuses)})
340
2x
    return statuses, nil
341
}
342

343
// UpdateHeartbeat updates the heartbeat for a worker instance
344
2x
func (s *WorkerService) UpdateHeartbeat(ctx context.Context, instance string) (err error) {
345
2x
    ctx, span := observability.TraceWorkerFunction(ctx, "update_heartbeat", attribute.String("worker.instance", instance))
346
2x
    defer observability.FinishSpan(span, &err)
347
2x

348
2x
    _, err = s.db.ExecContext(ctx, `
349
2x
        INSERT INTO worker_status (worker_instance, last_heartbeat, updated_at)
350
2x
        VALUES ($1, NOW(), NOW())
351
2x
        ON CONFLICT (worker_instance) DO UPDATE SET
352
2x
            last_heartbeat = EXCLUDED.last_heartbeat,
353
2x
            updated_at = EXCLUDED.updated_at
354
2x
    `, instance)
355
2x
    if err != nil {
356
        s.logger.Error(ctx, "Failed to update heartbeat", err, map[string]interface{}{"worker_instance": instance})
357
        return contextutils.WrapErrorf(err, "failed to update heartbeat for instance %s", instance)
358
    }
359

360
2x
    s.logger.Debug(ctx, "Heartbeat updated", map[string]interface{}{"worker_instance": instance})
361
2x
    return nil
362
}
363

364
// IsWorkerHealthy checks if a worker instance is healthy based on recent heartbeat
365
6x
func (s *WorkerService) IsWorkerHealthy(ctx context.Context, instance string) (result0 bool, err error) {
366
6x
    ctx, span := observability.TraceWorkerFunction(ctx, "is_worker_healthy", attribute.String("worker.instance", instance))
367
6x
    defer observability.FinishSpan(span, &err)
368
6x

369
6x
    var lastHeartbeat sql.NullTime
370
6x
    err = s.db.QueryRowContext(ctx, `
371
6x
        SELECT last_heartbeat FROM worker_status WHERE worker_instance = $1
372
6x
    `, instance).Scan(&lastHeartbeat)
373
6x
    if err != nil {
374
1x
        if err == sql.ErrNoRows {
375
1x
            s.logger.Debug(ctx, "Worker not found, considered unhealthy", map[string]interface{}{"worker_instance": instance})
376
1x
            return false, nil
377
1x
        }
378
        s.logger.Error(ctx, "Failed to check worker health", err, map[string]interface{}{"worker_instance": instance})
379
        return false, contextutils.WrapErrorf(err, "failed to check worker health for instance %s", instance)
380
    }
381

382
5x
    if !lastHeartbeat.Valid {
383
        s.logger.Debug(ctx, "Worker has no heartbeat, considered unhealthy", map[string]interface{}{"worker_instance": instance})
384
        return false, nil
385
    }
386

387
    // Consider worker healthy if heartbeat is within the last 5 minutes
388
5x
    healthy := time.Since(lastHeartbeat.Time) < 5*time.Minute
389
5x
    s.logger.Debug(ctx, "Worker health checked", map[string]interface{}{
390
5x
        "worker_instance": instance,
391
5x
        "healthy":         healthy,
392
5x
        "last_heartbeat":  lastHeartbeat.Time,
393
5x
        "time_since":      time.Since(lastHeartbeat.Time).String(),
394
5x
    })
395
5x
    return healthy, nil
396
}
397

398
// PauseWorker pauses a specific worker instance
399
3x
func (s *WorkerService) PauseWorker(ctx context.Context, instance string) (err error) {
400
3x
    ctx, span := observability.TraceWorkerFunction(ctx, "pause_worker", attribute.String("worker.instance", instance))
401
3x
    defer observability.FinishSpan(span, &err)
402
3x

403
3x
    _, err = s.db.ExecContext(ctx, `
404
3x
        UPDATE worker_status SET is_paused = true, updated_at = NOW()
405
3x
        WHERE worker_instance = $1
406
3x
    `, instance)
407
3x
    if err != nil {
408
        s.logger.Error(ctx, "Failed to pause worker", err, map[string]interface{}{"worker_instance": instance})
409
        return contextutils.WrapErrorf(err, "failed to pause worker instance %s", instance)
410
    }
411

412
3x
    s.logger.Info(ctx, "Worker paused", map[string]interface{}{"worker_instance": instance})
413
3x
    return nil
414
}
415

416
// ResumeWorker resumes a specific worker instance
417
3x
func (s *WorkerService) ResumeWorker(ctx context.Context, instance string) (err error) {
418
3x
    ctx, span := observability.TraceWorkerFunction(ctx, "resume_worker", attribute.String("worker.instance", instance))
419
3x
    defer observability.FinishSpan(span, &err)
420
3x

421
3x
    _, err = s.db.ExecContext(ctx, `
422
3x
        UPDATE worker_status SET is_paused = false, updated_at = NOW()
423
3x
        WHERE worker_instance = $1
424
3x
    `, instance)
425
3x
    if err != nil {
426
        s.logger.Error(ctx, "Failed to resume worker", err, map[string]interface{}{"worker_instance": instance})
427
        return contextutils.WrapErrorf(err, "failed to resume worker instance %s", instance)
428
    }
429

430
3x
    s.logger.Info(ctx, "Worker resumed", map[string]interface{}{"worker_instance": instance})
431
3x
    return nil
432
}
433

434
// GetWorkerHealth returns a map of worker health information
435
1x
func (s *WorkerService) GetWorkerHealth(ctx context.Context) (result0 map[string]interface{}, err error) {
436
1x
    ctx, span := observability.TraceWorkerFunction(ctx, "get_worker_health")
437
1x
    defer observability.FinishSpan(span, &err)
438
1x

439
1x
    var statuses []models.WorkerStatus
440
1x
    statuses, err = s.GetAllWorkerStatuses(ctx)
441
1x
    if err != nil {
442
        return nil, err
443
    }
444

445
1x
    var globalPaused bool
446
1x
    globalPaused, err = s.IsGlobalPaused(ctx)
447
1x
    if err != nil {
448
        s.logger.Error(ctx, "Failed to get global pause state", err, map[string]interface{}{})
449
        globalPaused = false // Default to false if we can't get the state
450
    }
451

452
1x
    health := make(map[string]interface{})
453
1x
    workerInstances := make([]map[string]interface{}, 0)
454
1x
    healthyCount := 0
455
1x
    totalCount := len(statuses)
456
1x

457
1x
    for _, status := range statuses {
458
3x
        healthy, err := s.IsWorkerHealthy(ctx, status.WorkerInstance)
459
3x
        if err != nil {
460
            s.logger.Error(ctx, "Failed to check health for worker", err, map[string]interface{}{"worker_instance": status.WorkerInstance})
461
            continue
462
        }
463

464
3x
        if healthy {
465
3x
            healthyCount++
466
3x
        }
467

468
3x
        workerInstance := map[string]interface{}{
469
3x
            "worker_instance":           status.WorkerInstance,
470
3x
            "healthy":                   healthy,
471
3x
            "is_running":                status.IsRunning,
472
3x
            "is_paused":                 status.IsPaused,
473
3x
            "last_heartbeat":            status.LastHeartbeat,
474
3x
            "total_questions_generated": status.TotalQuestionsGenerated,
475
3x
            "total_runs":                status.TotalRuns,
476
3x
        }
477
3x
        workerInstances = append(workerInstances, workerInstance)
478
    }
479

480
    // Build comprehensive health summary
481
1x
    health["global_paused"] = globalPaused
482
1x
    health["worker_instances"] = workerInstances
483
1x
    health["total_count"] = totalCount
484
1x
    health["healthy_count"] = healthyCount
485
1x

486
1x
    s.logger.Debug(ctx, "Worker health retrieved", map[string]interface{}{"worker_count": len(health)})
487
1x
    return health, nil
488
}
489

490
// GetHighPriorityTopics returns topics with high average priority scores for a user
491
1x
func (s *WorkerService) GetHighPriorityTopics(ctx context.Context, userID int, language, level, questionType string) (result0 []string, err error) {
492
1x
    ctx, span := observability.TraceWorkerFunction(ctx, "get_high_priority_topics",
493
1x
        observability.AttributeUserID(userID),
494
1x
        observability.AttributeLanguage(language),
495
1x
        observability.AttributeLevel(level),
496
1x
        attribute.String("question.type", questionType),
497
1x
    )
498
1x
    defer observability.FinishSpan(span, &err)
499
1x

500
1x
    query := `
501
1x
        SELECT q.topic_category, AVG(qps.priority_score) as avg_score
502
1x
        FROM questions q
503
1x
        JOIN user_questions uq ON q.id = uq.question_id
504
1x
        JOIN question_priority_scores qps ON q.id = qps.question_id AND qps.user_id = $1
505
1x
        WHERE uq.user_id = $1
506
1x
        AND q.language = $2
507
1x
        AND q.level = $3
508
1x
        AND q.type = $4
509
1x
        AND q.topic_category IS NOT NULL
510
1x
        AND q.topic_category != ''
511
1x
        GROUP BY q.topic_category
512
1x
        HAVING AVG(qps.priority_score) >= 7.0
513
1x
        ORDER BY avg_score DESC
514
1x
        LIMIT 5
515
1x
    `
516
1x
    rows, err := s.db.QueryContext(ctx, query, userID, language, level, questionType)
517
1x
    if err != nil {
518
        s.logger.Error(ctx, "Failed to get high priority topics", err, map[string]interface{}{
519
            "user_id": userID, "language": language, "level": level, "question_type": questionType,
520
        })
521
        return nil, contextutils.WrapError(err, "failed to get high priority topics")
522
    }
523
1x
    defer func() {
524
1x
        if err := rows.Close(); err != nil {
525
            s.logger.Error(ctx, "Failed to close rows", err, map[string]interface{}{})
526
        }
527
    }()
528
1x
    var topics []string
529
1x
    for rows.Next() {
530
        var topic string
531
        var avgScore float64
532
        if err := rows.Scan(&topic, &avgScore); err != nil {
533
            s.logger.Error(ctx, "Failed to scan high priority topics row", err, map[string]interface{}{})
534
            return nil, contextutils.WrapError(err, "failed to scan high priority topics row")
535
        }
536
        topics = append(topics, topic)
537
    }
538
1x
    if err := rows.Err(); err != nil {
539
        s.logger.Error(ctx, "Error iterating high priority topics rows", err, map[string]interface{}{})
540
        return nil, contextutils.WrapError(err, "error iterating high priority topics rows")
541
    }
542
1x
    s.logger.Debug(ctx, "Retrieved high priority topics", map[string]interface{}{"user_id": userID, "count": len(topics)})
543
1x
    return topics, nil
544
}
545

546
// GetGapAnalysis identifies areas with poor user performance (knowledge gaps)
547
1x
func (s *WorkerService) GetGapAnalysis(ctx context.Context, userID int, language, level, questionType string) (result0 map[string]int, err error) {
548
1x
    ctx, span := observability.TraceWorkerFunction(ctx, "get_gap_analysis",
549
1x
        observability.AttributeUserID(userID),
550
1x
        observability.AttributeLanguage(language),
551
1x
        observability.AttributeLevel(level),
552
1x
        attribute.String("question.type", questionType),
553
1x
    )
554
1x
    defer observability.FinishSpan(span, &err)
555
1x

556
1x
    // Query to find areas where user has poor performance (low accuracy)
557
1x
    // This analyzes gaps in user's knowledge across topics and varieties
558
1x
    query := `
559
1x
        WITH user_performance AS (
560
1x
            SELECT
561
1x
                q.topic_category,
562
1x
                q.grammar_focus,
563
1x
                q.vocabulary_domain,
564
1x
                q.scenario,
565
1x
                COUNT(*) as total_questions,
566
1x
                COUNT(CASE WHEN ur.is_correct = true THEN 1 END) as correct_answers,
567
1x
                ROUND(
568
1x
                    COUNT(CASE WHEN ur.is_correct = true THEN 1 END)::decimal / COUNT(*)::decimal * 100, 2
569
1x
                ) as accuracy_percentage
570
1x
            FROM questions q
571
1x
            JOIN user_questions uq ON q.id = uq.question_id
572
1x
            LEFT JOIN user_responses ur ON q.id = ur.question_id AND ur.user_id = $1
573
1x
            WHERE uq.user_id = $1
574
1x
            AND q.language = $2
575
1x
            AND q.level = $3
576
1x
            AND q.type = $4
577
1x
            GROUP BY q.topic_category, q.grammar_focus, q.vocabulary_domain, q.scenario
578
1x
        )
579
1x
        SELECT
580
1x
            COALESCE(topic_category, 'unknown') as area,
581
1x
            'topic' as gap_type,
582
1x
            total_questions,
583
1x
            accuracy_percentage
584
1x
        FROM user_performance
585
1x
        WHERE accuracy_percentage < 60 OR accuracy_percentage IS NULL
586
1x
        UNION ALL
587
1x
        SELECT
588
1x
            COALESCE(grammar_focus, 'unknown') as area,
589
1x
            'grammar' as gap_type,
590
1x
            total_questions,
591
1x
            accuracy_percentage
592
1x
        FROM user_performance
593
1x
        WHERE (accuracy_percentage < 60 OR accuracy_percentage IS NULL) AND grammar_focus IS NOT NULL
594
1x
        UNION ALL
595
1x
        SELECT
596
1x
            COALESCE(vocabulary_domain, 'unknown') as area,
597
1x
            'vocabulary' as gap_type,
598
1x
            total_questions,
599
1x
            accuracy_percentage
600
1x
        FROM user_performance
601
1x
        WHERE (accuracy_percentage < 60 OR accuracy_percentage IS NULL) AND vocabulary_domain IS NOT NULL
602
1x
        UNION ALL
603
1x
        SELECT
604
1x
            COALESCE(scenario, 'unknown') as area,
605
1x
            'scenario' as gap_type,
606
1x
            total_questions,
607
1x
            accuracy_percentage
608
1x
        FROM user_performance
609
1x
        WHERE (accuracy_percentage < 60 OR accuracy_percentage IS NULL) AND scenario IS NOT NULL
610
1x
        ORDER BY accuracy_percentage ASC, total_questions DESC
611
1x
    `
612
1x

613
1x
    rows, err := s.db.QueryContext(ctx, query, userID, language, level, questionType)
614
1x
    if err != nil {
615
        s.logger.Error(ctx, "Failed to get gap analysis", err, map[string]interface{}{
616
            "user_id": userID, "language": language, "level": level, "question_type": questionType,
617
        })
618
        return nil, contextutils.WrapError(err, "failed to get gap analysis")
619
    }
620
1x
    defer func() {
621
1x
        if err := rows.Close(); err != nil {
622
            s.logger.Error(ctx, "Failed to close rows", err, map[string]interface{}{})
623
        }
624
    }()
625

626
1x
    gaps := make(map[string]int)
627
1x
    for rows.Next() {
628
1x
        var area, gapType string
629
1x
        var totalQuestions int
630
1x
        var accuracyPercentage sql.NullFloat64
631
1x

632
1x
        if err := rows.Scan(&area, &gapType, &totalQuestions, &accuracyPercentage); err != nil {
633
            s.logger.Error(ctx, "Failed to scan gap analysis row", err, map[string]interface{}{})
634
            return nil, contextutils.WrapError(err, "failed to scan gap analysis row")
635
        }
636

637
        // Create a key that includes the gap type for better identification
638
1x
        key := fmt.Sprintf("%s_%s", gapType, area)
639
1x

640
1x
        // Use the number of questions as the gap severity indicator
641
1x
        // Areas with more questions but poor performance are bigger gaps
642
1x
        gaps[key] = totalQuestions
643
    }
644

645
1x
    if err := rows.Err(); err != nil {
646
        s.logger.Error(ctx, "Error iterating gap analysis rows", err, map[string]interface{}{})
647
        return nil, contextutils.WrapError(err, "error iterating gap analysis rows")
648
    }
649
1x
    s.logger.Debug(ctx, "Retrieved gap analysis", map[string]interface{}{"user_id": userID, "count": len(gaps)})
650
1x
    return gaps, nil
651
}
652

653
// GetPriorityDistribution returns the distribution of priority scores by topic
654
1x
func (s *WorkerService) GetPriorityDistribution(ctx context.Context, userID int, language, level, questionType string) (result0 map[string]int, err error) {
655
1x
    ctx, span := observability.TraceWorkerFunction(ctx, "get_priority_distribution",
656
1x
        observability.AttributeUserID(userID),
657
1x
        observability.AttributeLanguage(language),
658
1x
        observability.AttributeLevel(level),
659
1x
        attribute.String("question.type", questionType),
660
1x
    )
661
1x
    defer observability.FinishSpan(span, &err)
662
1x

663
1x
    // Query to get priority score distribution by topic
664
1x
    query := `
665
1x
        SELECT q.topic_category, COUNT(*) as question_count
666
1x
        FROM questions q
667
1x
        JOIN user_questions uq ON q.id = uq.question_id
668
1x
        JOIN question_priority_scores qps ON q.id = qps.question_id AND qps.user_id = $1
669
1x
        WHERE uq.user_id = $1
670
1x
        AND q.language = $2
671
1x
        AND q.level = $3
672
1x
        AND q.type = $4
673
1x
        GROUP BY q.topic_category
674
1x
    `
675
1x

676
1x
    rows, err := s.db.QueryContext(ctx, query, userID, language, level, questionType)
677
1x
    if err != nil {
678
        s.logger.Error(ctx, "Failed to get priority distribution", err, map[string]interface{}{
679
            "user_id": userID, "language": language, "level": level, "question_type": questionType,
680
        })
681
        return nil, contextutils.WrapError(err, "failed to get priority distribution")
682
    }
683
1x
    defer func() {
684
1x
        if err := rows.Close(); err != nil {
685
            s.logger.Error(ctx, "Failed to close rows", err, map[string]interface{}{})
686
        }
687
    }()
688

689
1x
    distribution := make(map[string]int)
690
1x
    for rows.Next() {
691
        var topic string
692
        var count int
693
        if err := rows.Scan(&topic, &count); err != nil {
694
            s.logger.Error(ctx, "Failed to scan priority distribution row", err, map[string]interface{}{})
695
            return nil, contextutils.WrapError(err, "failed to scan priority distribution row")
696
        }
697
        distribution[topic] = count
698
    }
699

700
1x
    if err := rows.Err(); err != nil {
701
        s.logger.Error(ctx, "Error iterating priority distribution rows", err, map[string]interface{}{})
702
        return nil, contextutils.WrapError(err, "error iterating priority distribution rows")
703
    }
704
1x
    s.logger.Debug(ctx, "Retrieved priority distribution", map[string]interface{}{"user_id": userID, "count": len(distribution)})
705
1x
    return distribution, nil
706
}
707

708
// GetNotificationStats returns comprehensive notification statistics
709
func (s *WorkerService) GetNotificationStats(ctx context.Context) (result0 map[string]interface{}, err error) {
710
    ctx, span := observability.TraceWorkerFunction(ctx, "get_notification_stats")
711
    defer observability.FinishSpan(span, &err)
712

713
    // Get total notifications sent
714
    var totalSent int
715
    err = s.db.QueryRowContext(ctx, `
716
        SELECT COUNT(*) FROM sent_notifications WHERE status = 'sent'
717
    `).Scan(&totalSent)
718
    if err != nil {
719
        s.logger.Error(ctx, "Failed to get total notifications sent", err, map[string]interface{}{})
720
        return nil, contextutils.WrapError(err, "failed to get total notifications sent")
721
    }
722

723
    // Get total notifications failed
724
    var totalFailed int
725
    err = s.db.QueryRowContext(ctx, `
726
        SELECT COUNT(*) FROM sent_notifications WHERE status = 'failed'
727
    `).Scan(&totalFailed)
728
    if err != nil {
729
        s.logger.Error(ctx, "Failed to get total notifications failed", err, map[string]interface{}{})
730
        return nil, contextutils.WrapError(err, "failed to get total notifications failed")
731
    }
732

733
    // Calculate success rate
734
    var successRate float64
735
    if totalSent+totalFailed > 0 {
736
        successRate = float64(totalSent) / float64(totalSent+totalFailed)
737
    }
738

739
    // Get users with notifications enabled
740
    var usersWithNotifications int
741
    err = s.db.QueryRowContext(ctx, `
742
        SELECT COUNT(DISTINCT user_id) FROM user_learning_preferences WHERE daily_reminder_enabled = true
743
    `).Scan(&usersWithNotifications)
744
    if err != nil {
745
        s.logger.Error(ctx, "Failed to get users with notifications enabled", err, map[string]interface{}{})
746
        return nil, contextutils.WrapError(err, "failed to get users with notifications enabled")
747
    }
748

749
    // Get total users
750
    var totalUsers int
751
    err = s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM users`).Scan(&totalUsers)
752
    if err != nil {
753
        s.logger.Error(ctx, "Failed to get total users", err, map[string]interface{}{})
754
        return nil, contextutils.WrapError(err, "failed to get total users")
755
    }
756

757
    // Get notifications sent today
758
    var sentToday int
759
    err = s.db.QueryRowContext(ctx, `
760
        SELECT COUNT(*) FROM sent_notifications
761
        WHERE status = 'sent' AND DATE(sent_at) = CURRENT_DATE
762
    `).Scan(&sentToday)
763
    if err != nil {
764
        s.logger.Error(ctx, "Failed to get notifications sent today", err, map[string]interface{}{})
765
        return nil, contextutils.WrapError(err, "failed to get notifications sent today")
766
    }
767

768
    // Get notifications sent this week
769
    var sentThisWeek int
770
    err = s.db.QueryRowContext(ctx, `
771
        SELECT COUNT(*) FROM sent_notifications
772
        WHERE status = 'sent' AND sent_at >= DATE_TRUNC('week', CURRENT_DATE)
773
    `).Scan(&sentThisWeek)
774
    if err != nil {
775
        s.logger.Error(ctx, "Failed to get notifications sent this week", err, map[string]interface{}{})
776
        return nil, contextutils.WrapError(err, "failed to get notifications sent this week")
777
    }
778

779
    // Get upcoming notifications
780
    var upcomingNotifications int
781
    err = s.db.QueryRowContext(ctx, `
782
        SELECT COUNT(*) FROM upcoming_notifications WHERE status = 'pending'
783
    `).Scan(&upcomingNotifications)
784
    if err != nil {
785
        s.logger.Error(ctx, "Failed to get upcoming notifications", err, map[string]interface{}{})
786
        return nil, contextutils.WrapError(err, "failed to get upcoming notifications")
787
    }
788

789
    // Get unresolved errors
790
    var unresolvedErrors int
791
    err = s.db.QueryRowContext(ctx, `
792
        SELECT COUNT(*) FROM notification_errors WHERE resolved_at IS NULL
793
    `).Scan(&unresolvedErrors)
794
    if err != nil {
795
        s.logger.Error(ctx, "Failed to get unresolved errors", err, map[string]interface{}{})
796
        return nil, contextutils.WrapError(err, "failed to get unresolved errors")
797
    }
798

799
    // Get notifications by type
800
    notificationsByType := make(map[string]int)
801
    rows, err := s.db.QueryContext(ctx, `
802
        SELECT notification_type, COUNT(*)
803
        FROM sent_notifications
804
        WHERE status = 'sent'
805
        GROUP BY notification_type
806
    `)
807
    if err != nil {
808
        s.logger.Error(ctx, "Failed to get notifications by type", err, map[string]interface{}{})
809
        return nil, contextutils.WrapError(err, "failed to get notifications by type")
810
    }
811
    defer func() {
812
        if closeErr := rows.Close(); closeErr != nil {
813
            s.logger.Error(ctx, "Failed to close rows", closeErr, map[string]interface{}{})
814
        }
815
    }()
816

817
    for rows.Next() {
818
        var notificationType string
819
        var count int
820
        if err := rows.Scan(&notificationType, &count); err != nil {
821
            s.logger.Error(ctx, "Failed to scan notifications by type", err, map[string]interface{}{})
822
            return nil, contextutils.WrapError(err, "failed to scan notifications by type")
823
        }
824
        notificationsByType[notificationType] = count
825
    }
826

827
    // Get errors by type
828
    errorsByType := make(map[string]int)
829
    rows, err = s.db.QueryContext(ctx, `
830
        SELECT error_type, COUNT(*)
831
        FROM notification_errors
832
        GROUP BY error_type
833
    `)
834
    if err != nil {
835
        s.logger.Error(ctx, "Failed to get errors by type", err, map[string]interface{}{})
836
        return nil, contextutils.WrapError(err, "failed to get errors by type")
837
    }
838
    defer func() {
839
        if closeErr := rows.Close(); closeErr != nil {
840
            s.logger.Error(ctx, "Failed to close rows", closeErr, map[string]interface{}{})
841
        }
842
    }()
843

844
    for rows.Next() {
845
        var errorType string
846
        var count int
847
        if err := rows.Scan(&errorType, &count); err != nil {
848
            s.logger.Error(ctx, "Failed to scan errors by type", err, map[string]interface{}{})
849
            return nil, contextutils.WrapError(err, "failed to scan errors by type")
850
        }
851
        errorsByType[errorType] = count
852
    }
853

854
    stats := map[string]interface{}{
855
        "total_notifications_sent":         totalSent,
856
        "total_notifications_failed":       totalFailed,
857
        "success_rate":                     successRate,
858
        "users_with_notifications_enabled": usersWithNotifications,
859
        "total_users":                      totalUsers,
860
        "notifications_sent_today":         sentToday,
861
        "notifications_sent_this_week":     sentThisWeek,
862
        "notifications_by_type":            notificationsByType,
863
        "errors_by_type":                   errorsByType,
864
        "upcoming_notifications":           upcomingNotifications,
865
        "unresolved_errors":                unresolvedErrors,
866
    }
867

868
    s.logger.Debug(ctx, "Retrieved notification stats", map[string]interface{}{"stats": stats})
869
    return stats, nil
870
}
871

872
// GetNotificationErrors returns paginated notification errors with filtering
873
func (s *WorkerService) GetNotificationErrors(ctx context.Context, page, pageSize int, errorType, notificationType, resolved string) (result0 []map[string]interface{}, result1, result2 map[string]interface{}, err error) {
874
    ctx, span := observability.TraceWorkerFunction(ctx, "get_notification_errors",
875
        attribute.Int("page", page),
876
        attribute.Int("page_size", pageSize),
877
        attribute.String("error_type", errorType),
878
        attribute.String("notification_type", notificationType),
879
        attribute.String("resolved", resolved),
880
    )
881
    defer observability.FinishSpan(span, &err)
882

883
    // Build WHERE clause
884
    whereConditions := []string{}
885
    args := []interface{}{}
886
    argIndex := 1
887

888
    if errorType != "" {
889
        whereConditions = append(whereConditions, fmt.Sprintf("error_type = $%d", argIndex))
890
        args = append(args, errorType)
891
        argIndex++
892
    }
893

894
    if notificationType != "" {
895
        whereConditions = append(whereConditions, fmt.Sprintf("notification_type = $%d", argIndex))
896
        args = append(args, notificationType)
897
        argIndex++
898
    }
899

900
    switch resolved {
901
    case "true":
902
        whereConditions = append(whereConditions, "resolved_at IS NOT NULL")
903
    case "false":
904
        whereConditions = append(whereConditions, "resolved_at IS NULL")
905
    }
906

907
    whereClause := ""
908
    if len(whereConditions) > 0 {
909
        whereClause = "WHERE " + strings.Join(whereConditions, " AND ")
910
    }
911

912
    // Get total count
913
    var totalErrors int
914
    countQuery := fmt.Sprintf("SELECT COUNT(*) FROM notification_errors %s", whereClause)
915
    err = s.db.QueryRowContext(ctx, countQuery, args...).Scan(&totalErrors)
916
    if err != nil {
917
        s.logger.Error(ctx, "Failed to get total notification errors", err, map[string]interface{}{})
918
        return nil, nil, nil, contextutils.WrapError(err, "failed to get total notification errors")
919
    }
920

921
    // Calculate pagination
922
    offset := (page - 1) * pageSize
923
    totalPages := (totalErrors + pageSize - 1) / pageSize
924

925
    // Get errors with pagination
926
    args = append(args, pageSize, offset)
927
    query := fmt.Sprintf(`
928
        SELECT ne.id, ne.user_id, u.username, ne.notification_type, ne.error_type,
929
               ne.error_message, ne.email_address, ne.occurred_at, ne.resolved_at, ne.resolution_notes
930
        FROM notification_errors ne
931
        LEFT JOIN users u ON ne.user_id = u.id
932
        %s
933
        ORDER BY ne.occurred_at DESC
934
        LIMIT $%d OFFSET $%d
935
    `, whereClause, argIndex, argIndex+1)
936

937
    rows, err := s.db.QueryContext(ctx, query, args...)
938
    if err != nil {
939
        s.logger.Error(ctx, "Failed to get notification errors", err, map[string]interface{}{})
940
        return nil, nil, nil, contextutils.WrapError(err, "failed to get notification errors")
941
    }
942
    defer func() {
943
        if closeErr := rows.Close(); closeErr != nil {
944
            s.logger.Error(ctx, "Failed to close rows", closeErr, map[string]interface{}{})
945
        }
946
    }()
947

948
    var errors []map[string]interface{}
949
    for rows.Next() {
950
        var errorData map[string]interface{}
951
        var id int
952
        var userID sql.NullInt64
953
        var username sql.NullString
954
        var notificationType, errorType, errorMessage string
955
        var emailAddress sql.NullString
956
        var occurredAt time.Time
957
        var resolvedAt sql.NullTime
958
        var resolutionNotes sql.NullString
959

960
        err := rows.Scan(&id, &userID, &username, &notificationType, &errorType, &errorMessage, &emailAddress, &occurredAt, &resolvedAt, &resolutionNotes)
961
        if err != nil {
962
            s.logger.Error(ctx, "Failed to scan notification error", err, map[string]interface{}{})
963
            return nil, nil, nil, contextutils.WrapError(err, "failed to scan notification error")
964
        }
965

966
        errorData = map[string]interface{}{
967
            "id":                id,
968
            "notification_type": notificationType,
969
            "error_type":        errorType,
970
            "error_message":     errorMessage,
971
            "occurred_at":       occurredAt.Format(time.RFC3339),
972
        }
973

974
        if userID.Valid {
975
            errorData["user_id"] = userID.Int64
976
        }
977
        if username.Valid {
978
            errorData["username"] = username.String
979
        }
980
        if emailAddress.Valid {
981
            errorData["email_address"] = emailAddress.String
982
        }
983
        if resolvedAt.Valid {
984
            errorData["resolved_at"] = resolvedAt.Time.Format(time.RFC3339)
985
        }
986
        if resolutionNotes.Valid {
987
            errorData["resolution_notes"] = resolutionNotes.String
988
        }
989

990
        errors = append(errors, errorData)
991
    }
992

993
    // Get stats
994
    stats := map[string]interface{}{
995
        "total_errors":      totalErrors,
996
        "unresolved_errors": 0, // Will be calculated separately
997
    }
998

999
    // Get unresolved errors count
1000
    var unresolvedCount int
1001
    err = s.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM notification_errors WHERE resolved_at IS NULL").Scan(&unresolvedCount)
1002
    if err != nil {
1003
        s.logger.Error(ctx, "Failed to get unresolved errors count", err, map[string]interface{}{})
1004
    } else {
1005
        stats["unresolved_errors"] = unresolvedCount
1006
    }
1007

1008
    // Get errors by type
1009
    errorsByType := make(map[string]int)
1010
    rows, err = s.db.QueryContext(ctx, "SELECT error_type, COUNT(*) FROM notification_errors GROUP BY error_type")
1011
    if err != nil {
1012
        s.logger.Error(ctx, "Failed to get errors by type", err, map[string]interface{}{})
1013
    } else {
1014
        defer func() {
1015
            if closeErr := rows.Close(); closeErr != nil {
1016
                s.logger.Error(ctx, "Failed to close rows", closeErr, map[string]interface{}{})
1017
            }
1018
        }()
1019
        for rows.Next() {
1020
            var errorType string
1021
            var count int
1022
            if err := rows.Scan(&errorType, &count); err != nil {
1023
                s.logger.Error(ctx, "Failed to scan errors by type", err, map[string]interface{}{})
1024
                continue
1025
            }
1026
            errorsByType[errorType] = count
1027
        }
1028
        stats["errors_by_type"] = errorsByType
1029
    }
1030

1031
    // Get errors by notification type
1032
    errorsByNotificationType := make(map[string]int)
1033
    rows, err = s.db.QueryContext(ctx, "SELECT notification_type, COUNT(*) FROM notification_errors GROUP BY notification_type")
1034
    if err != nil {
1035
        s.logger.Error(ctx, "Failed to get errors by notification type", err, map[string]interface{}{})
1036
    } else {
1037
        defer func() {
1038
            if closeErr := rows.Close(); closeErr != nil {
1039
                s.logger.Error(ctx, "Failed to close rows", closeErr, map[string]interface{}{})
1040
            }
1041
        }()
1042
        for rows.Next() {
1043
            var notificationType string
1044
            var count int
1045
            if err := rows.Scan(&notificationType, &count); err != nil {
1046
                s.logger.Error(ctx, "Failed to scan errors by notification type", err, map[string]interface{}{})
1047
                continue
1048
            }
1049
            errorsByNotificationType[notificationType] = count
1050
        }
1051
        stats["errors_by_notification_type"] = errorsByNotificationType
1052
    }
1053

1054
    pagination := map[string]interface{}{
1055
        "page":        page,
1056
        "page_size":   pageSize,
1057
        "total":       totalErrors,
1058
        "total_pages": totalPages,
1059
    }
1060

1061
    s.logger.Debug(ctx, "Retrieved notification errors", map[string]interface{}{
1062
        "count": len(errors), "page": page, "total": totalErrors,
1063
    })
1064

1065
    return errors, pagination, stats, nil
1066
}
1067

1068
// GetUpcomingNotifications returns paginated upcoming notifications with filtering
1069
func (s *WorkerService) GetUpcomingNotifications(ctx context.Context, page, pageSize int, notificationType, status, scheduledAfter, scheduledBefore string) (result0 []map[string]interface{}, result1, result2 map[string]interface{}, err error) {
1070
    ctx, span := observability.TraceWorkerFunction(ctx, "get_upcoming_notifications",
1071
        attribute.Int("page", page),
1072
        attribute.Int("page_size", pageSize),
1073
        attribute.String("notification_type", notificationType),
1074
        attribute.String("status", status),
1075
        attribute.String("scheduled_after", scheduledAfter),
1076
        attribute.String("scheduled_before", scheduledBefore),
1077
    )
1078
    defer observability.FinishSpan(span, &err)
1079

1080
    // Build WHERE clause
1081
    whereConditions := []string{}
1082
    args := []interface{}{}
1083
    argIndex := 1
1084

1085
    if notificationType != "" {
1086
        whereConditions = append(whereConditions, fmt.Sprintf("notification_type = $%d", argIndex))
1087
        args = append(args, notificationType)
1088
        argIndex++
1089
    }
1090

1091
    if status != "" {
1092
        whereConditions = append(whereConditions, fmt.Sprintf("status = $%d", argIndex))
1093
        args = append(args, status)
1094
        argIndex++
1095
    }
1096

1097
    if scheduledAfter != "" {
1098
        whereConditions = append(whereConditions, fmt.Sprintf("scheduled_for >= $%d", argIndex))
1099
        args = append(args, scheduledAfter)
1100
        argIndex++
1101
    }
1102

1103
    if scheduledBefore != "" {
1104
        whereConditions = append(whereConditions, fmt.Sprintf("scheduled_for <= $%d", argIndex))
1105
        args = append(args, scheduledBefore)
1106
        argIndex++
1107
    }
1108

1109
    whereClause := ""
1110
    if len(whereConditions) > 0 {
1111
        whereClause = "WHERE " + strings.Join(whereConditions, " AND ")
1112
    }
1113

1114
    // Get total count
1115
    var totalNotifications int
1116
    countQuery := fmt.Sprintf("SELECT COUNT(*) FROM upcoming_notifications %s", whereClause)
1117
    err = s.db.QueryRowContext(ctx, countQuery, args...).Scan(&totalNotifications)
1118
    if err != nil {
1119
        s.logger.Error(ctx, "Failed to get total upcoming notifications", err, map[string]interface{}{})
1120
        return nil, nil, nil, contextutils.WrapError(err, "failed to get total upcoming notifications")
1121
    }
1122

1123
    // Calculate pagination
1124
    offset := (page - 1) * pageSize
1125
    totalPages := (totalNotifications + pageSize - 1) / pageSize
1126

1127
    // Get notifications with pagination
1128
    args = append(args, pageSize, offset)
1129
    query := fmt.Sprintf(`
1130
        SELECT un.id, un.user_id, u.username, u.email, un.notification_type,
1131
               un.scheduled_for, un.status, un.created_at
1132
        FROM upcoming_notifications un
1133
        LEFT JOIN users u ON un.user_id = u.id
1134
        %s
1135
        ORDER BY un.scheduled_for ASC
1136
        LIMIT $%d OFFSET $%d
1137
    `, whereClause, argIndex, argIndex+1)
1138

1139
    rows, err := s.db.QueryContext(ctx, query, args...)
1140
    if err != nil {
1141
        s.logger.Error(ctx, "Failed to get upcoming notifications", err, map[string]interface{}{})
1142
        return nil, nil, nil, contextutils.WrapError(err, "failed to get upcoming notifications")
1143
    }
1144
    defer func() {
1145
        if closeErr := rows.Close(); closeErr != nil {
1146
            s.logger.Error(ctx, "Failed to close rows", closeErr, map[string]interface{}{})
1147
        }
1148
    }()
1149

1150
    var notifications []map[string]interface{}
1151
    for rows.Next() {
1152
        var notification map[string]interface{}
1153
        var id, userID int
1154
        var username, notificationType, status string
1155
        var scheduledFor, createdAt time.Time
1156
        var email sql.NullString
1157

1158
        err := rows.Scan(&id, &userID, &username, &email, &notificationType, &scheduledFor, &status, &createdAt)
1159
        if err != nil {
1160
            s.logger.Error(ctx, "Failed to scan upcoming notification", err, map[string]interface{}{})
1161
            return nil, nil, nil, contextutils.WrapError(err, "failed to scan upcoming notification")
1162
        }
1163

1164
        notification = map[string]interface{}{
1165
            "id":                id,
1166
            "user_id":           userID,
1167
            "username":          username,
1168
            "notification_type": notificationType,
1169
            "scheduled_for":     scheduledFor.Format(time.RFC3339),
1170
            "status":            status,
1171
            "created_at":        createdAt.Format(time.RFC3339),
1172
        }
1173

1174
        if email.Valid {
1175
            notification["email_address"] = email.String
1176
        } else {
1177
            notification["email_address"] = ""
1178
        }
1179

1180
        notifications = append(notifications, notification)
1181
    }
1182

1183
    // Get stats
1184
    stats := map[string]interface{}{
1185
        "total_pending":             0,
1186
        "total_scheduled_today":     0,
1187
        "total_scheduled_this_week": 0,
1188
    }
1189

1190
    // Get total pending
1191
    var totalPending int
1192
    err = s.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM upcoming_notifications WHERE status = 'pending'").Scan(&totalPending)
1193
    if err != nil {
1194
        s.logger.Error(ctx, "Failed to get total pending", err, map[string]interface{}{})
1195
    } else {
1196
        stats["total_pending"] = totalPending
1197
    }
1198

1199
    // Get scheduled today
1200
    var scheduledToday int
1201
    err = s.db.QueryRowContext(ctx, `
1202
        SELECT COUNT(*) FROM upcoming_notifications
1203
        WHERE status = 'pending' AND DATE(scheduled_for) = CURRENT_DATE
1204
    `).Scan(&scheduledToday)
1205
    if err != nil {
1206
        s.logger.Error(ctx, "Failed to get scheduled today", err, map[string]interface{}{})
1207
    } else {
1208
        stats["total_scheduled_today"] = scheduledToday
1209
    }
1210

1211
    // Get scheduled this week
1212
    var scheduledThisWeek int
1213
    err = s.db.QueryRowContext(ctx, `
1214
        SELECT COUNT(*) FROM upcoming_notifications
1215
        WHERE status = 'pending' AND scheduled_for >= DATE_TRUNC('week', CURRENT_DATE)
1216
    `).Scan(&scheduledThisWeek)
1217
    if err != nil {
1218
        s.logger.Error(ctx, "Failed to get scheduled this week", err, map[string]interface{}{})
1219
    } else {
1220
        stats["total_scheduled_this_week"] = scheduledThisWeek
1221
    }
1222

1223
    // Get notifications by type
1224
    notificationsByType := make(map[string]int)
1225
    rows, err = s.db.QueryContext(ctx, "SELECT notification_type, COUNT(*) FROM upcoming_notifications GROUP BY notification_type")
1226
    if err != nil {
1227
        s.logger.Error(ctx, "Failed to get notifications by type", err, map[string]interface{}{})
1228
    } else {
1229
        defer func() {
1230
            if closeErr := rows.Close(); closeErr != nil {
1231
                s.logger.Error(ctx, "Failed to close rows", closeErr, map[string]interface{}{})
1232
            }
1233
        }()
1234
        for rows.Next() {
1235
            var notificationType string
1236
            var count int
1237
            if err := rows.Scan(&notificationType, &count); err != nil {
1238
                s.logger.Error(ctx, "Failed to scan notifications by type", err, map[string]interface{}{})
1239
                continue
1240
            }
1241
            notificationsByType[notificationType] = count
1242
        }
1243
        stats["notifications_by_type"] = notificationsByType
1244
    }
1245

1246
    pagination := map[string]interface{}{
1247
        "page":        page,
1248
        "page_size":   pageSize,
1249
        "total":       totalNotifications,
1250
        "total_pages": totalPages,
1251
    }
1252

1253
    s.logger.Debug(ctx, "Retrieved upcoming notifications", map[string]interface{}{
1254
        "count": len(notifications), "page": page, "total": totalNotifications,
1255
    })
1256

1257
    return notifications, pagination, stats, nil
1258
}
1259

1260
// GetSentNotifications returns paginated sent notifications with filtering
1261
func (s *WorkerService) GetSentNotifications(ctx context.Context, page, pageSize int, notificationType, status, sentAfter, sentBefore string) (result0 []map[string]interface{}, result1, result2 map[string]interface{}, err error) {
1262
    ctx, span := observability.TraceWorkerFunction(ctx, "get_sent_notifications",
1263
        attribute.Int("page", page),
1264
        attribute.Int("page_size", pageSize),
1265
        attribute.String("notification_type", notificationType),
1266
        attribute.String("status", status),
1267
        attribute.String("sent_after", sentAfter),
1268
        attribute.String("sent_before", sentBefore),
1269
    )
1270
    defer observability.FinishSpan(span, &err)
1271

1272
    // Build WHERE clause
1273
    whereConditions := []string{}
1274
    args := []interface{}{}
1275
    argIndex := 1
1276

1277
    if notificationType != "" {
1278
        whereConditions = append(whereConditions, fmt.Sprintf("notification_type = $%d", argIndex))
1279
        args = append(args, notificationType)
1280
        argIndex++
1281
    }
1282

1283
    if status != "" {
1284
        whereConditions = append(whereConditions, fmt.Sprintf("status = $%d", argIndex))
1285
        args = append(args, status)
1286
        argIndex++
1287
    }
1288

1289
    if sentAfter != "" {
1290
        whereConditions = append(whereConditions, fmt.Sprintf("sent_at >= $%d", argIndex))
1291
        args = append(args, sentAfter)
1292
        argIndex++
1293
    }
1294

1295
    if sentBefore != "" {
1296
        whereConditions = append(whereConditions, fmt.Sprintf("sent_at <= $%d", argIndex))
1297
        args = append(args, sentBefore)
1298
        argIndex++
1299
    }
1300

1301
    whereClause := ""
1302
    if len(whereConditions) > 0 {
1303
        whereClause = "WHERE " + strings.Join(whereConditions, " AND ")
1304
    }
1305

1306
    // Get total count
1307
    var totalNotifications int
1308
    countQuery := fmt.Sprintf("SELECT COUNT(*) FROM sent_notifications %s", whereClause)
1309
    err = s.db.QueryRowContext(ctx, countQuery, args...).Scan(&totalNotifications)
1310
    if err != nil {
1311
        s.logger.Error(ctx, "Failed to get total sent notifications", err, map[string]interface{}{})
1312
        return nil, nil, nil, contextutils.WrapError(err, "failed to get total sent notifications")
1313
    }
1314

1315
    // Calculate pagination
1316
    offset := (page - 1) * pageSize
1317
    totalPages := (totalNotifications + pageSize - 1) / pageSize
1318

1319
    // Get notifications with pagination
1320
    args = append(args, pageSize, offset)
1321
    query := fmt.Sprintf(`
1322
        SELECT sn.id, sn.user_id, u.username, u.email, sn.notification_type,
1323
               sn.subject, sn.template_name, sn.sent_at, sn.status, sn.error_message, sn.retry_count
1324
        FROM sent_notifications sn
1325
        LEFT JOIN users u ON sn.user_id = u.id
1326
        %s
1327
        ORDER BY sn.sent_at DESC
1328
        LIMIT $%d OFFSET $%d
1329
    `, whereClause, argIndex, argIndex+1)
1330

1331
    rows, err := s.db.QueryContext(ctx, query, args...)
1332
    if err != nil {
1333
        s.logger.Error(ctx, "Failed to get sent notifications", err, map[string]interface{}{})
1334
        return nil, nil, nil, contextutils.WrapError(err, "failed to get sent notifications")
1335
    }
1336
    defer func() {
1337
        if closeErr := rows.Close(); closeErr != nil {
1338
            s.logger.Error(ctx, "Failed to close rows", closeErr, map[string]interface{}{})
1339
        }
1340
    }()
1341

1342
    var notifications []map[string]interface{}
1343
    for rows.Next() {
1344
        var notification map[string]interface{}
1345
        var id, userID int
1346
        var username, notificationType, subject, templateName, status string
1347
        var sentAt time.Time
1348
        var errorMessage sql.NullString
1349
        var retryCount int
1350
        var email sql.NullString
1351

1352
        err := rows.Scan(&id, &userID, &username, &email, &notificationType, &subject, &templateName, &sentAt, &status, &errorMessage, &retryCount)
1353
        if err != nil {
1354
            s.logger.Error(ctx, "Failed to scan sent notification", err, map[string]interface{}{})
1355
            return nil, nil, nil, contextutils.WrapError(err, "failed to scan sent notification")
1356
        }
1357

1358
        notification = map[string]interface{}{
1359
            "id":                id,
1360
            "user_id":           userID,
1361
            "username":          username,
1362
            "notification_type": notificationType,
1363
            "subject":           subject,
1364
            "template_name":     templateName,
1365
            "sent_at":           sentAt.Format(time.RFC3339),
1366
            "status":            status,
1367
            "retry_count":       retryCount,
1368
        }
1369

1370
        if email.Valid {
1371
            notification["email_address"] = email.String
1372
        } else {
1373
            notification["email_address"] = ""
1374
        }
1375

1376
        if errorMessage.Valid {
1377
            notification["error_message"] = errorMessage.String
1378
        }
1379

1380
        notifications = append(notifications, notification)
1381
    }
1382

1383
    // Get stats
1384
    stats := map[string]interface{}{
1385
        "total_sent":     0,
1386
        "total_failed":   0,
1387
        "success_rate":   0.0,
1388
        "sent_today":     0,
1389
        "sent_this_week": 0,
1390
    }
1391

1392
    // Get total sent
1393
    var totalSent int
1394
    err = s.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM sent_notifications WHERE status = 'sent'").Scan(&totalSent)
1395
    if err != nil {
1396
        s.logger.Error(ctx, "Failed to get total sent", err, map[string]interface{}{})
1397
    } else {
1398
        stats["total_sent"] = totalSent
1399
    }
1400

1401
    // Get total failed
1402
    var totalFailed int
1403
    err = s.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM sent_notifications WHERE status = 'failed'").Scan(&totalFailed)
1404
    if err != nil {
1405
        s.logger.Error(ctx, "Failed to get total failed", err, map[string]interface{}{})
1406
    } else {
1407
        stats["total_failed"] = totalFailed
1408
    }
1409

1410
    // Calculate success rate
1411
    if totalSent+totalFailed > 0 {
1412
        stats["success_rate"] = float64(totalSent) / float64(totalSent+totalFailed)
1413
    }
1414

1415
    // Get sent today
1416
    var sentToday int
1417
    err = s.db.QueryRowContext(ctx, `
1418
        SELECT COUNT(*) FROM sent_notifications
1419
        WHERE status = 'sent' AND DATE(sent_at) = CURRENT_DATE
1420
    `).Scan(&sentToday)
1421
    if err != nil {
1422
        s.logger.Error(ctx, "Failed to get sent today", err, map[string]interface{}{})
1423
    } else {
1424
        stats["sent_today"] = sentToday
1425
    }
1426

1427
    // Get sent this week
1428
    var sentThisWeek int
1429
    err = s.db.QueryRowContext(ctx, `
1430
        SELECT COUNT(*) FROM sent_notifications
1431
        WHERE status = 'sent' AND sent_at >= DATE_TRUNC('week', CURRENT_DATE)
1432
    `).Scan(&sentThisWeek)
1433
    if err != nil {
1434
        s.logger.Error(ctx, "Failed to get sent this week", err, map[string]interface{}{})
1435
    } else {
1436
        stats["sent_this_week"] = sentThisWeek
1437
    }
1438

1439
    // Get notifications by type
1440
    notificationsByType := make(map[string]int)
1441
    rows, err = s.db.QueryContext(ctx, "SELECT notification_type, COUNT(*) FROM sent_notifications GROUP BY notification_type")
1442
    if err != nil {
1443
        s.logger.Error(ctx, "Failed to get notifications by type", err, map[string]interface{}{})
1444
    } else {
1445
        defer func() {
1446
            if closeErr := rows.Close(); closeErr != nil {
1447
                s.logger.Error(ctx, "Failed to close rows", closeErr, map[string]interface{}{})
1448
            }
1449
        }()
1450
        for rows.Next() {
1451
            var notificationType string
1452
            var count int
1453
            if err := rows.Scan(&notificationType, &count); err != nil {
1454
                s.logger.Error(ctx, "Failed to scan notifications by type", err, map[string]interface{}{})
1455
                continue
1456
            }
1457
            notificationsByType[notificationType] = count
1458
        }
1459
        stats["notifications_by_type"] = notificationsByType
1460
    }
1461

1462
    pagination := map[string]interface{}{
1463
        "page":        page,
1464
        "page_size":   pageSize,
1465
        "total":       totalNotifications,
1466
        "total_pages": totalPages,
1467
    }
1468

1469
    s.logger.Debug(ctx, "Retrieved sent notifications", map[string]interface{}{
1470
        "count": len(notifications), "page": page, "total": totalNotifications,
1471
    })
1472

1473
    return notifications, pagination, stats, nil
1474
}
1475

1476
// CreateTestSentNotification creates a test sent notification for testing purposes
1477
func (s *WorkerService) CreateTestSentNotification(ctx context.Context, userID int, notificationType, subject, templateName, status, errorMessage string) error {
1478
    ctx, span := observability.TraceWorkerFunction(ctx, "create_test_sent_notification",
1479
        attribute.Int("user.id", userID),
1480
        attribute.String("notification.type", notificationType),
1481
        attribute.String("notification.status", status),
1482
    )
1483
    defer span.End()
1484

1485
    query := `
1486
        INSERT INTO sent_notifications (user_id, notification_type, subject, template_name, sent_at, status, error_message)
1487
        VALUES ($1, $2, $3, $4, $5, $6, $7)
1488
    `
1489

1490
    _, err := s.db.ExecContext(ctx, query, userID, notificationType, subject, templateName, time.Now(), status, errorMessage)
1491
    if err != nil {
1492
        span.RecordError(err)
1493
        s.logger.Error(ctx, "Failed to create test sent notification", err, map[string]interface{}{
1494
            "user_id":           userID,
1495
            "notification_type": notificationType,
1496
            "status":            status,
1497
        })
1498
        return contextutils.WrapError(err, "failed to create test sent notification")
1499
    }
1500

1501
    s.logger.Info(ctx, "Created test sent notification", map[string]interface{}{
1502
        "user_id":           userID,
1503
        "notification_type": notificationType,
1504
        "status":            status,
1505
    })
1506

1507
    return nil
1508
}
1509


			
quizapp internal utils
70.0%
Statements
147/210
errors.go
82.4%
56/68
localization.go
67.5%
56/83
security.go
100.0%
5/5
time.go
54.7%
29/53
validation.go
100.0%
1/1
quizapp internal utils validation.go
82.4%
Statements
56/68
1
// Package contextutils provides error handling utilities and standardized error types
2
// for consistent error management across the quiz application.
3
package contextutils
4

5
import (
6
    "context"
7
    "fmt"
8
    "strings"
9
)
10

11
// ErrorCode represents a standardized error code for API responses
12
type ErrorCode string
13

14
const (
15
    // Database error codes
16

17
    // ErrorCodeDatabaseConnection indicates a database connection error
18
    ErrorCodeDatabaseConnection ErrorCode = "DATABASE_CONNECTION_ERROR"
19
    // ErrorCodeDatabaseQuery indicates a database query error
20
    ErrorCodeDatabaseQuery ErrorCode = "DATABASE_QUERY_ERROR"
21
    // ErrorCodeDatabaseTransaction indicates a database transaction error
22
    ErrorCodeDatabaseTransaction ErrorCode = "DATABASE_TRANSACTION_ERROR"
23
    // ErrorCodeRecordNotFound indicates that a requested record was not found
24
    ErrorCodeRecordNotFound ErrorCode = "RECORD_NOT_FOUND"
25
    // ErrorCodeRecordExists indicates that a record already exists (duplicate key)
26
    ErrorCodeRecordExists ErrorCode = "RECORD_ALREADY_EXISTS"
27
    // ErrorCodeForeignKeyViolation indicates a foreign key constraint violation
28
    ErrorCodeForeignKeyViolation ErrorCode = "FOREIGN_KEY_VIOLATION"
29

30
    // Validation error codes
31

32
    // ErrorCodeInvalidInput indicates that the provided input is invalid
33
    ErrorCodeInvalidInput ErrorCode = "INVALID_INPUT"
34
    // ErrorCodeMissingRequired indicates that a required field is missing
35
    ErrorCodeMissingRequired ErrorCode = "MISSING_REQUIRED_FIELD"
36
    // ErrorCodeInvalidFormat indicates that the input format is invalid
37
    ErrorCodeInvalidFormat ErrorCode = "INVALID_FORMAT"
38
    // ErrorCodeValidationFailed indicates that validation has failed
39
    ErrorCodeValidationFailed ErrorCode = "VALIDATION_FAILED"
40

41
    // Authentication error codes
42

43
    // ErrorCodeUnauthorized indicates that the user is not authorized
44
    ErrorCodeUnauthorized ErrorCode = "UNAUTHORIZED"
45
    // ErrorCodeForbidden indicates that the user is forbidden from accessing the resource
46
    ErrorCodeForbidden ErrorCode = "FORBIDDEN"
47
    // ErrorCodeInvalidCredentials indicates that the provided credentials are invalid
48
    ErrorCodeInvalidCredentials ErrorCode = "INVALID_CREDENTIALS"
49
    // ErrorCodeSessionExpired indicates that the user session has expired
50
    ErrorCodeSessionExpired ErrorCode = "SESSION_EXPIRED"
51

52
    // Service error codes
53

54
    // ErrorCodeServiceUnavailable indicates that the service is temporarily unavailable
55
    ErrorCodeServiceUnavailable ErrorCode = "SERVICE_UNAVAILABLE"
56
    // ErrorCodeTimeout indicates that a request has timed out
57
    ErrorCodeTimeout ErrorCode = "REQUEST_TIMEOUT"
58
    // ErrorCodeRateLimit indicates that the rate limit has been exceeded
59
    ErrorCodeRateLimit ErrorCode = "RATE_LIMIT_EXCEEDED"
60
    // ErrorCodeQuotaExceeded indicates that the usage quota has been exceeded
61
    ErrorCodeQuotaExceeded ErrorCode = "QUOTA_EXCEEDED"
62
    // ErrorCodeInternalError indicates an internal server error
63
    ErrorCodeInternalError ErrorCode = "INTERNAL_SERVER_ERROR"
64
    // ErrorCodeAssignmentNotFound indicates that a question assignment was not found
65
    ErrorCodeAssignmentNotFound ErrorCode = "ASSIGNMENT_NOT_FOUND"
66
    // ErrorCodeConflict indicates that an operation conflicts with the current state
67
    ErrorCodeConflict ErrorCode = "CONFLICT"
68

69
    // Question error codes
70

71
    // ErrorCodeTimestampMissingTimezone indicates that a timestamp is missing timezone information
72
    ErrorCodeTimestampMissingTimezone ErrorCode = "TIMESTAMP_MISSING_TIMEZONE"
73
    // ErrorCodeNoQuestionsAvailable indicates that no questions are available
74
    ErrorCodeNoQuestionsAvailable ErrorCode = "NO_QUESTIONS_AVAILABLE"
75
    // ErrorCodeQuestionAlreadyAnswered indicates that the question has already been answered
76
    ErrorCodeQuestionAlreadyAnswered ErrorCode = "QUESTION_ALREADY_ANSWERED"
77
    // ErrorCodeQuestionNotFound indicates that the requested question was not found
78
    ErrorCodeQuestionNotFound ErrorCode = "QUESTION_NOT_FOUND"
79
    // ErrorCodeInvalidAnswerIndex indicates that the answer index is invalid
80
    ErrorCodeInvalidAnswerIndex ErrorCode = "INVALID_ANSWER_INDEX"
81
    // ErrorCodeGenerationLimitReached indicates that the daily generation limit has been reached
82
    ErrorCodeGenerationLimitReached ErrorCode = "GENERATION_LIMIT_REACHED"
83

84
    // AI Service error codes
85

86
    // ErrorCodeAIProviderUnavailable indicates that the AI provider is unavailable
87
    ErrorCodeAIProviderUnavailable ErrorCode = "AI_PROVIDER_UNAVAILABLE"
88
    // ErrorCodeAIRequestFailed indicates that the AI request failed
89
    ErrorCodeAIRequestFailed ErrorCode = "AI_REQUEST_FAILED"
90
    // ErrorCodeAIResponseInvalid indicates that the AI response is invalid
91
    ErrorCodeAIResponseInvalid ErrorCode = "AI_RESPONSE_INVALID"
92
    // ErrorCodeAIConfigInvalid indicates that the AI configuration is invalid
93
    ErrorCodeAIConfigInvalid ErrorCode = "AI_CONFIG_INVALID"
94

95
    // OAuth error codes
96

97
    // ErrorCodeOAuthCodeExpired indicates that the OAuth authorization code has expired
98
    ErrorCodeOAuthCodeExpired ErrorCode = "OAUTH_CODE_EXPIRED"
99
    // ErrorCodeOAuthStateMismatch indicates that the OAuth state parameter does not match
100
    ErrorCodeOAuthStateMismatch ErrorCode = "OAUTH_STATE_MISMATCH"
101
    // ErrorCodeOAuthProviderError indicates an error from the OAuth provider
102
    ErrorCodeOAuthProviderError ErrorCode = "OAUTH_PROVIDER_ERROR"
103
)
104

105
// SeverityLevel represents the severity of an error for logging and monitoring
106
type SeverityLevel string
107

108
const (
109
    // SeverityDebug indicates debug-level errors for development
110
    SeverityDebug SeverityLevel = "debug"
111
    // SeverityInfo indicates informational errors
112
    SeverityInfo SeverityLevel = "info"
113
    // SeverityWarn indicates warning-level errors
114
    SeverityWarn SeverityLevel = "warn"
115
    // SeverityError indicates error-level issues
116
    SeverityError SeverityLevel = "error"
117
    // SeverityFatal indicates fatal errors that require immediate attention
118
    SeverityFatal SeverityLevel = "fatal"
119
)
120

121
// AppError represents a structured error with code, severity, and context
122
type AppError struct {
123
    Code     ErrorCode
124
    Severity SeverityLevel
125
    Message  string
126
    Details  string
127
    Cause    error
128
}
129

130
// Error implements the error interface
131
5x
func (e *AppError) Error() string {
132
5x
    if e.Details != "" {
133
1x
        return fmt.Sprintf("%s: %s - %s", e.Code, e.Message, e.Details)
134
1x
    }
135
4x
    return fmt.Sprintf("%s: %s", e.Code, e.Message)
136
}
137

138
// Unwrap returns the underlying cause error
139
2x
func (e *AppError) Unwrap() error {
140
2x
    return e.Cause
141
2x
}
142

143
// Is implements error comparison for errors.Is
144
5x
func (e *AppError) Is(target error) bool {
145
5x
    if appErr, ok := target.(*AppError); ok {
146
3x
        return e.Code == appErr.Code
147
3x
    }
148
2x
    return false
149
}
150

151
// Error types for consistent error handling with associated codes and severity
152
var (
153
    // Database errors
154
    ErrDatabaseConnection = &AppError{
155
        Code:     ErrorCodeDatabaseConnection,
156
        Severity: SeverityError,
157
        Message:  "Database connection failed",
158
    }
159

160
    ErrDatabaseQuery = &AppError{
161
        Code:     ErrorCodeDatabaseQuery,
162
        Severity: SeverityError,
163
        Message:  "Database query failed",
164
    }
165

166
    ErrDatabaseTransaction = &AppError{
167
        Code:     ErrorCodeDatabaseTransaction,
168
        Severity: SeverityError,
169
        Message:  "Database transaction failed",
170
    }
171

172
    ErrRecordNotFound = &AppError{
173
        Code:     ErrorCodeRecordNotFound,
174
        Severity: SeverityInfo,
175
        Message:  "Record not found",
176
    }
177

178
    ErrRecordExists = &AppError{
179
        Code:     ErrorCodeRecordExists,
180
        Severity: SeverityInfo,
181
        Message:  "Record already exists",
182
    }
183

184
    ErrForeignKeyViolation = &AppError{
185
        Code:     ErrorCodeForeignKeyViolation,
186
        Severity: SeverityError,
187
        Message:  "Foreign key constraint violation",
188
    }
189

190
    // Validation errors
191
    ErrInvalidInput = &AppError{
192
        Code:     ErrorCodeInvalidInput,
193
        Severity: SeverityWarn,
194
        Message:  "Invalid input",
195
    }
196

197
    ErrMissingRequired = &AppError{
198
        Code:     ErrorCodeMissingRequired,
199
        Severity: SeverityWarn,
200
        Message:  "Missing required field",
201
    }
202

203
    ErrInvalidFormat = &AppError{
204
        Code:     ErrorCodeInvalidFormat,
205
        Severity: SeverityWarn,
206
        Message:  "Invalid format",
207
    }
208

209
    ErrValidationFailed = &AppError{
210
        Code:     ErrorCodeValidationFailed,
211
        Severity: SeverityWarn,
212
        Message:  "Validation failed",
213
    }
214

215
    // Authentication errors
216
    ErrUnauthorized = &AppError{
217
        Code:     ErrorCodeUnauthorized,
218
        Severity: SeverityWarn,
219
        Message:  "Unauthorized",
220
    }
221

222
    ErrForbidden = &AppError{
223
        Code:     ErrorCodeForbidden,
224
        Severity: SeverityWarn,
225
        Message:  "Forbidden",
226
    }
227

228
    ErrInvalidCredentials = &AppError{
229
        Code:     ErrorCodeInvalidCredentials,
230
        Severity: SeverityWarn,
231
        Message:  "Invalid credentials",
232
    }
233

234
    ErrSessionExpired = &AppError{
235
        Code:     ErrorCodeSessionExpired,
236
        Severity: SeverityInfo,
237
        Message:  "Session expired",
238
    }
239

240
    // Service errors
241
    ErrServiceUnavailable = &AppError{
242
        Code:     ErrorCodeServiceUnavailable,
243
        Severity: SeverityError,
244
        Message:  "Service unavailable",
245
    }
246

247
    ErrTimeout = &AppError{
248
        Code:     ErrorCodeTimeout,
249
        Severity: SeverityWarn,
250
        Message:  "Request timeout",
251
    }
252

253
    ErrRateLimit = &AppError{
254
        Code:     ErrorCodeRateLimit,
255
        Severity: SeverityWarn,
256
        Message:  "Rate limit exceeded",
257
    }
258

259
    ErrQuotaExceeded = &AppError{
260
        Code:     ErrorCodeQuotaExceeded,
261
        Severity: SeverityWarn,
262
        Message:  "Usage quota exceeded",
263
    }
264

265
    ErrInternalError = &AppError{
266
        Code:     ErrorCodeInternalError,
267
        Severity: SeverityError,
268
        Message:  "Internal server error",
269
    }
270

271
    ErrAssignmentNotFound = &AppError{
272
        Code:     ErrorCodeAssignmentNotFound,
273
        Severity: SeverityInfo,
274
        Message:  "Assignment not found",
275
    }
276

277
    ErrConflict = &AppError{
278
        Code:     ErrorCodeConflict,
279
        Severity: SeverityWarn,
280
        Message:  "Operation conflicts with current state",
281
    }
282

283
    // Question errors
284
    ErrTimestampMissingTimezone = &AppError{
285
        Code:     ErrorCodeTimestampMissingTimezone,
286
        Severity: SeverityError,
287
        Message:  "Timestamp missing timezone",
288
    }
289

290
    ErrNoQuestionsAvailable = &AppError{
291
        Code:     ErrorCodeNoQuestionsAvailable,
292
        Severity: SeverityInfo,
293
        Message:  "No questions available for assignment",
294
    }
295

296
    ErrQuestionAlreadyAnswered = &AppError{
297
        Code:     ErrorCodeQuestionAlreadyAnswered,
298
        Severity: SeverityInfo,
299
        Message:  "Question already answered",
300
    }
301

302
    ErrQuestionNotFound = &AppError{
303
        Code:     ErrorCodeQuestionNotFound,
304
        Severity: SeverityInfo,
305
        Message:  "Question not found",
306
    }
307

308
    ErrInvalidAnswerIndex = &AppError{
309
        Code:     ErrorCodeInvalidAnswerIndex,
310
        Severity: SeverityWarn,
311
        Message:  "Invalid answer index",
312
    }
313

314
    ErrGenerationLimitReached = &AppError{
315
        Code:     ErrorCodeGenerationLimitReached,
316
        Severity: SeverityInfo,
317
        Message:  "Daily generation limit reached",
318
    }
319

320
    // AI Service errors
321
    ErrAIProviderUnavailable = &AppError{
322
        Code:     ErrorCodeAIProviderUnavailable,
323
        Severity: SeverityError,
324
        Message:  "AI provider unavailable",
325
    }
326

327
    ErrAIRequestFailed = &AppError{
328
        Code:     ErrorCodeAIRequestFailed,
329
        Severity: SeverityError,
330
        Message:  "AI request failed",
331
    }
332

333
    ErrAIResponseInvalid = &AppError{
334
        Code:     ErrorCodeAIResponseInvalid,
335
        Severity: SeverityError,
336
        Message:  "AI response invalid",
337
    }
338

339
    ErrAIConfigInvalid = &AppError{
340
        Code:     ErrorCodeAIConfigInvalid,
341
        Severity: SeverityError,
342
        Message:  "AI configuration invalid",
343
    }
344

345
    // OAuth errors
346
    ErrOAuthCodeExpired = &AppError{
347
        Code:     ErrorCodeOAuthCodeExpired,
348
        Severity: SeverityWarn,
349
        Message:  "OAuth code expired",
350
    }
351

352
    ErrOAuthStateMismatch = &AppError{
353
        Code:     ErrorCodeOAuthStateMismatch,
354
        Severity: SeverityError,
355
        Message:  "OAuth state mismatch",
356
    }
357

358
    ErrOAuthProviderError = &AppError{
359
        Code:     ErrorCodeOAuthProviderError,
360
        Severity: SeverityError,
361
        Message:  "OAuth provider error",
362
    }
363
)
364

365
// NewAppError creates a new AppError with the specified code, severity, message and details
366
1x
func NewAppError(code ErrorCode, severity SeverityLevel, message, details string) *AppError {
367
1x
    return &AppError{
368
1x
        Code:     code,
369
1x
        Severity: severity,
370
1x
        Message:  message,
371
1x
        Details:  details,
372
1x
    }
373
1x
}
374

375
// NewAppErrorWithCause creates a new AppError with an underlying cause
376
1x
func NewAppErrorWithCause(code ErrorCode, severity SeverityLevel, message, details string, cause error) *AppError {
377
1x
    return &AppError{
378
1x
        Code:     code,
379
1x
        Severity: severity,
380
1x
        Message:  message,
381
1x
        Details:  details,
382
1x
        Cause:    cause,
383
1x
    }
384
1x
}
385

386
// WrapError wraps an error with additional context, preserving AppError structure if possible
387
4x
func WrapError(err error, context string) error {
388
4x
    if err == nil {
389
1x
        return nil
390
1x
    }
391

392
    // If it's already an AppError, wrap it with additional details
393
3x
    if appErr, ok := err.(*AppError); ok {
394
1x
        return &AppError{
395
1x
            Code:     appErr.Code,
396
1x
            Severity: appErr.Severity,
397
1x
            Message:  context,
398
1x
            Details:  appErr.Error(),
399
1x
            Cause:    appErr,
400
1x
        }
401
1x
    }
402

403
    // For regular errors, create a generic internal error wrapper
404
2x
    return &AppError{
405
2x
        Code:     ErrorCodeInternalError,
406
2x
        Severity: SeverityError,
407
2x
        Message:  context,
408
2x
        Details:  err.Error(),
409
2x
        Cause:    err,
410
2x
    }
411
}
412

413
// WrapErrorf wraps an error with formatted context, preserving AppError structure if possible
414
3x
func WrapErrorf(err error, format string, args ...interface{}) error {
415
3x
    if err == nil {
416
        return nil
417
    }
418

419
    // Handle %w verb for error wrapping by using fmt.Errorf
420
3x
    if strings.Contains(format, "%w") {
421
2x
        // Use fmt.Errorf to properly handle %w verb
422
2x
        wrappedErr := fmt.Errorf(format, args...)
423
2x

424
2x
        // If it's already an AppError, wrap it with the formatted message
425
2x
        if appErr, ok := err.(*AppError); ok {
426
1x
            return &AppError{
427
1x
                Code:     appErr.Code,
428
1x
                Severity: appErr.Severity,
429
1x
                Message:  wrappedErr.Error(),
430
1x
                Details:  appErr.Error(),
431
1x
                Cause:    wrappedErr,
432
1x
            }
433
1x
        }
434

435
        // For regular errors, wrap with the formatted error
436
1x
        return &AppError{
437
1x
            Code:     ErrorCodeInternalError,
438
1x
            Severity: SeverityError,
439
1x
            Message:  wrappedErr.Error(),
440
1x
            Details:  err.Error(),
441
1x
            Cause:    wrappedErr,
442
1x
        }
443
    }
444

445
    // If it's already an AppError, wrap it with additional details
446
1x
    if appErr, ok := err.(*AppError); ok {
447
        context := fmt.Sprintf(format, args...)
448
        return &AppError{
449
            Code:     appErr.Code,
450
            Severity: appErr.Severity,
451
            Message:  context,
452
            Details:  appErr.Error(),
453
            Cause:    appErr,
454
        }
455
    }
456

457
    // For regular errors, create a generic internal error wrapper
458
1x
    context := fmt.Sprintf(format, args...)
459
1x
    return &AppError{
460
1x
        Code:     ErrorCodeInternalError,
461
1x
        Severity: SeverityError,
462
1x
        Message:  context,
463
1x
        Details:  err.Error(),
464
1x
        Cause:    err,
465
1x
    }
466
}
467

468
// ErrorWithContextf creates a new error with formatted context
469
1x
func ErrorWithContextf(format string, args ...interface{}) error {
470
1x
    return &AppError{
471
1x
        Code:     ErrorCodeInternalError,
472
1x
        Severity: SeverityError,
473
1x
        Message:  fmt.Sprintf(format, args...),
474
1x
    }
475
1x
}
476

477
// IsError checks if an error matches a specific AppError type
478
3x
func IsError(err error, target *AppError) bool {
479
3x
    if appErr, ok := err.(*AppError); ok {
480
2x
        return appErr.Code == target.Code
481
2x
    }
482
1x
    return false
483
}
484

485
// AsError attempts to convert an error to an AppError
486
2x
func AsError(err error, target **AppError) bool {
487
2x
    if appErr, ok := err.(*AppError); ok {
488
1x
        *target = appErr
489
1x
        return true
490
1x
    }
491
1x
    return false
492
}
493

494
// GetErrorCode returns the error code from an error if it's an AppError, otherwise returns a default code
495
2x
func GetErrorCode(err error) ErrorCode {
496
2x
    if appErr, ok := err.(*AppError); ok {
497
1x
        return appErr.Code
498
1x
    }
499
1x
    return ErrorCodeInternalError
500
}
501

502
// GetErrorSeverity returns the severity level from an error if it's an AppError, otherwise returns error
503
2x
func GetErrorSeverity(err error) SeverityLevel {
504
2x
    if appErr, ok := err.(*AppError); ok {
505
1x
        return appErr.Severity
506
1x
    }
507
1x
    return SeverityError
508
}
509

510
// IsRetryable determines if an error should be retried based on its type and severity
511
8x
func IsRetryable(err error) bool {
512
8x
    if appErr, ok := err.(*AppError); ok {
513
7x
        // Only retry certain types of errors that are likely transient
514
7x
        switch appErr.Code {
515
4x
        case ErrorCodeTimeout, ErrorCodeServiceUnavailable, ErrorCodeDatabaseConnection:
516
4x
            return appErr.Severity != SeverityFatal
517
        }
518
    }
519
4x
    return false
520
}
521

522
// GetErrorLocalizedMessage returns a localized message for the error
523
3x
func GetErrorLocalizedMessage(err error, locale string) string {
524
3x
    if appErr, ok := err.(*AppError); ok {
525
2x
        return GetLocalizedMessageWithDetails(appErr.Code, ParseLocale(locale), appErr.Details)
526
2x
    }
527
1x
    return "An error occurred"
528
}
529

530
// ToJSON converts an AppError to a JSON-serializable structure for API responses
531
2x
func (e *AppError) ToJSON() map[string]interface{} {
532
2x
    result := map[string]interface{}{
533
2x
        "code":     string(e.Code),
534
2x
        "message":  e.Message,
535
2x
        "severity": string(e.Severity),
536
2x
        "error":    e.Message, // Include error field for backward compatibility
537
2x
    }
538
2x

539
2x
    if e.Details != "" {
540
2x
        result["details"] = e.Details
541
2x
    }
542

543
    // Add retryable information
544
2x
    result["retryable"] = IsRetryable(e)
545
2x

546
2x
    if e.Cause != nil {
547
1x
        // Only include cause in debug mode or for certain error types
548
1x
        switch e.Severity {
549
        case SeverityError, SeverityFatal:
550
            result["cause"] = e.Cause.Error()
551
        }
552
    }
553

554
2x
    return result
555
}
556

557
// ToJSONWithLocale converts an AppError to a JSON-serializable structure with localized messages
558
1x
func (e *AppError) ToJSONWithLocale(locale string) map[string]interface{} {
559
1x
    result := e.ToJSON()
560
1x
    // Replace the message with localized version and update error field too
561
1x
    localizedMessage := GetLocalizedMessage(e.Code, ParseLocale(locale))
562
1x
    result["message"] = localizedMessage
563
1x
    result["error"] = localizedMessage // Keep error field in sync
564
1x
    return result
565
1x
}
566

567
// ContextKey represents a context key type for passing values through context
568
type ContextKey string
569

570
const (
571
    // UserIDKey is used to store user ID in context for usage tracking
572
    UserIDKey ContextKey = "userID"
573
    // APIKeyIDKey is used to store API key ID in context for usage tracking
574
    APIKeyIDKey ContextKey = "apiKeyID"
575
)
576

577
// GetUserIDFromContext extracts the user ID from context, returning 0 if not found
578
func GetUserIDFromContext(ctx context.Context) int {
579
    if userID, ok := ctx.Value(UserIDKey).(int); ok {
580
        return userID
581
    }
582
    return 0 // Default fallback
583
}
584

585
// GetAPIKeyIDFromContext extracts the API key ID from context, returning nil if not found
586
func GetAPIKeyIDFromContext(ctx context.Context) *int {
587
    if apiKeyID, ok := ctx.Value(APIKeyIDKey).(*int); ok {
588
        return apiKeyID
589
    }
590
    return nil // Default fallback
591
}
592

593
// WithUserID returns a new context with the user ID set
594
func WithUserID(ctx context.Context, userID int) context.Context {
595
    return context.WithValue(ctx, UserIDKey, userID)
596
}
597

598
// WithAPIKeyID returns a new context with the API key ID set
599
func WithAPIKeyID(ctx context.Context, apiKeyID int) context.Context {
600
    return context.WithValue(ctx, APIKeyIDKey, &apiKeyID)
601
}
602


			
quizapp internal utils validation.go
67.5%
Statements
56/83
1
package contextutils
2

3
import (
4
    "encoding/json"
5
    "fmt"
6
    "strings"
7
)
8

9
// Locale represents a language locale (e.g., "en", "es", "fr")
10
type Locale string
11

12
const (
13
    // LocaleEnglish represents English language
14
    LocaleEnglish Locale = "en"
15
    // LocaleSpanish represents Spanish language
16
    LocaleSpanish Locale = "es"
17
    // LocaleFrench represents French language
18
    LocaleFrench Locale = "fr"
19
    // LocaleGerman represents German language
20
    LocaleGerman Locale = "de"
21
    // LocaleItalian represents Italian language
22
    LocaleItalian Locale = "it"
23
)
24

25
// LocalizedMessages contains localized error messages for different locales
26
type LocalizedMessages struct {
27
    messages map[ErrorCode]map[Locale]string
28
}
29

30
// NewLocalizedMessages creates a new instance of localized messages
31
7x
func NewLocalizedMessages() *LocalizedMessages {
32
7x
    return &LocalizedMessages{
33
7x
        messages: make(map[ErrorCode]map[Locale]string),
34
7x
    }
35
7x
}
36

37
// AddMessage adds a localized message for a specific error code and locale
38
23x
func (lm *LocalizedMessages) AddMessage(code ErrorCode, locale Locale, message string) {
39
23x
    if lm.messages[code] == nil {
40
11x
        lm.messages[code] = make(map[Locale]string)
41
11x
    }
42
23x
    lm.messages[code][locale] = message
43
}
44

45
// GetMessage returns the localized message for an error code and locale
46
16x
func (lm *LocalizedMessages) GetMessage(code ErrorCode, locale Locale) string {
47
16x
    // Try to get the message for the specific locale
48
16x
    if localeMessages, exists := lm.messages[code]; exists {
49
15x
        if message, exists := localeMessages[locale]; exists {
50
12x
            return message
51
12x
        }
52

53
        // Fallback to English if the specific locale doesn't have a message
54
3x
        if message, exists := localeMessages[LocaleEnglish]; exists {
55
1x
            return message
56
1x
        }
57
    }
58

59
    // Fallback to a default message
60
3x
    return getDefaultMessage(code)
61
}
62

63
// GetMessageWithDetails returns a localized message with additional details
64
4x
func (lm *LocalizedMessages) GetMessageWithDetails(code ErrorCode, locale Locale, details string) string {
65
4x
    message := lm.GetMessage(code, locale)
66
4x
    if details != "" {
67
3x
        return fmt.Sprintf("%s: %s", message, details)
68
3x
    }
69
1x
    return message
70
}
71

72
// getDefaultMessage returns a default English message for error codes
73
8x
func getDefaultMessage(code ErrorCode) string {
74
8x
    switch code {
75
    case ErrorCodeDatabaseConnection:
76
        return "Database connection failed"
77
    case ErrorCodeDatabaseQuery:
78
        return "Database query failed"
79
    case ErrorCodeDatabaseTransaction:
80
        return "Database transaction failed"
81
1x
    case ErrorCodeRecordNotFound:
82
1x
        return "Record not found"
83
    case ErrorCodeRecordExists:
84
        return "Record already exists"
85
    case ErrorCodeForeignKeyViolation:
86
        return "Foreign key constraint violation"
87
3x
    case ErrorCodeInvalidInput:
88
3x
        return "Invalid input"
89
    case ErrorCodeMissingRequired:
90
        return "Missing required field"
91
    case ErrorCodeInvalidFormat:
92
        return "Invalid format"
93
    case ErrorCodeValidationFailed:
94
        return "Validation failed"
95
1x
    case ErrorCodeUnauthorized:
96
1x
        return "Unauthorized access"
97
    case ErrorCodeForbidden:
98
        return "Access forbidden"
99
    case ErrorCodeInvalidCredentials:
100
        return "Invalid credentials"
101
    case ErrorCodeSessionExpired:
102
        return "Session expired"
103
    case ErrorCodeServiceUnavailable:
104
        return "Service temporarily unavailable"
105
    case ErrorCodeTimeout:
106
        return "Request timeout"
107
    case ErrorCodeRateLimit:
108
        return "Rate limit exceeded"
109
1x
    case ErrorCodeInternalError:
110
1x
        return "Internal server error"
111
    case ErrorCodeAssignmentNotFound:
112
        return "Assignment not found"
113
    case ErrorCodeTimestampMissingTimezone:
114
        return "Timestamp missing timezone"
115
    case ErrorCodeNoQuestionsAvailable:
116
        return "No questions available"
117
    case ErrorCodeQuestionAlreadyAnswered:
118
        return "Question already answered"
119
    case ErrorCodeQuestionNotFound:
120
        return "Question not found"
121
    case ErrorCodeInvalidAnswerIndex:
122
        return "Invalid answer index"
123
    case ErrorCodeAIProviderUnavailable:
124
        return "AI service unavailable"
125
    case ErrorCodeAIRequestFailed:
126
        return "AI request failed"
127
    case ErrorCodeAIResponseInvalid:
128
        return "AI response invalid"
129
    case ErrorCodeAIConfigInvalid:
130
        return "AI configuration invalid"
131
    case ErrorCodeOAuthCodeExpired:
132
        return "OAuth code expired"
133
    case ErrorCodeOAuthStateMismatch:
134
        return "OAuth state mismatch"
135
    case ErrorCodeOAuthProviderError:
136
        return "OAuth provider error"
137
2x
    default:
138
2x
        return "An error occurred"
139
    }
140
}
141

142
// LoadMessagesFromJSON loads localized messages from a JSON structure
143
2x
func (lm *LocalizedMessages) LoadMessagesFromJSON(jsonData string) error {
144
2x
    var data map[string]map[string]string
145
2x
    if err := json.Unmarshal([]byte(jsonData), &data); err != nil {
146
1x
        return WrapError(err, "failed to parse localization JSON")
147
1x
    }
148

149
1x
    for codeStr, localeMessages := range data {
150
2x
        code := ErrorCode(codeStr)
151
2x
        for localeStr, message := range localeMessages {
152
4x
            locale := Locale(localeStr)
153
4x
            lm.AddMessage(code, locale, message)
154
4x
        }
155
    }
156

157
1x
    return nil
158
}
159

160
// GetSupportedLocales returns a list of supported locales
161
1x
func (lm *LocalizedMessages) GetSupportedLocales() []Locale {
162
1x
    locales := make(map[Locale]bool)
163
1x

164
1x
    for _, localeMessages := range lm.messages {
165
2x
        for locale := range localeMessages {
166
3x
            locales[locale] = true
167
3x
        }
168
    }
169

170
1x
    result := make([]Locale, 0, len(locales))
171
1x
    for locale := range locales {
172
3x
        result = append(result, locale)
173
3x
    }
174

175
1x
    return result
176
}
177

178
// ParseLocale parses a locale string (e.g., "en-US", "fr-CA") and returns the language part
179
10x
func ParseLocale(localeStr string) Locale {
180
10x
    // Handle locale formats like "en-US", "fr-CA", etc.
181
10x
    parts := strings.Split(localeStr, "-")
182
10x
    if len(parts) > 0 && parts[0] != "" {
183
9x
        return Locale(strings.ToLower(parts[0]))
184
9x
    }
185
1x
    return LocaleEnglish // Default fallback
186
}
187

188
// Global instance of localized messages
189
var globalLocalizedMessages = NewLocalizedMessages()
190

191
// init loads default localized messages
192
1x
func init() {
193
1x
    // Load some basic localized messages
194
1x
    globalLocalizedMessages.AddMessage(ErrorCodeInvalidInput, LocaleSpanish, "Entrada invÃlida")
195
1x
    globalLocalizedMessages.AddMessage(ErrorCodeInvalidInput, LocaleFrench, "EntrÃe invalide")
196
1x
    globalLocalizedMessages.AddMessage(ErrorCodeInvalidInput, LocaleGerman, "UngÃltige Eingabe")
197
1x

198
1x
    globalLocalizedMessages.AddMessage(ErrorCodeRecordNotFound, LocaleSpanish, "Registro no encontrado")
199
1x
    globalLocalizedMessages.AddMessage(ErrorCodeRecordNotFound, LocaleFrench, "Enregistrement non trouvÃ")
200
1x
    globalLocalizedMessages.AddMessage(ErrorCodeRecordNotFound, LocaleGerman, "Datensatz nicht gefunden")
201
1x

202
1x
    globalLocalizedMessages.AddMessage(ErrorCodeUnauthorized, LocaleSpanish, "Acceso no autorizado")
203
1x
    globalLocalizedMessages.AddMessage(ErrorCodeUnauthorized, LocaleFrench, "AccÃs non autorisÃ")
204
1x
    globalLocalizedMessages.AddMessage(ErrorCodeUnauthorized, LocaleGerman, "Unbefugter Zugriff")
205
1x

206
1x
    globalLocalizedMessages.AddMessage(ErrorCodeInternalError, LocaleSpanish, "Error interno del servidor")
207
1x
    globalLocalizedMessages.AddMessage(ErrorCodeInternalError, LocaleFrench, "Erreur interne du serveur")
208
1x
    globalLocalizedMessages.AddMessage(ErrorCodeInternalError, LocaleGerman, "Interner Serverfehler")
209
1x
}
210

211
// GetLocalizedMessage returns a localized error message using the global instance
212
5x
func GetLocalizedMessage(code ErrorCode, locale Locale) string {
213
5x
    return globalLocalizedMessages.GetMessage(code, locale)
214
5x
}
215

216
// GetLocalizedMessageWithDetails returns a localized error message with details
217
2x
func GetLocalizedMessageWithDetails(code ErrorCode, locale Locale, details string) string {
218
2x
    return globalLocalizedMessages.GetMessageWithDetails(code, locale, details)
219
2x
}
220

221
// SetGlobalLocalizedMessages sets the global localized messages instance
222
1x
func SetGlobalLocalizedMessages(messages *LocalizedMessages) {
223
1x
    globalLocalizedMessages = messages
224
1x
}
225


			
quizapp internal utils validation.go
100.0%
Statements
5/5
1
package contextutils
2

3
import (
4
    "strings"
5
)
6

7
// MaskAPIKey masks an API key for logging purposes to prevent exposure
8
// Returns a masked version that shows only first 4 and last 4 characters
9
17x
func MaskAPIKey(apiKey string) string {
10
17x
    if apiKey == "" {
11
1x
        return "[EMPTY]"
12
1x
    }
13

14
16x
    if len(apiKey) <= 8 {
15
3x
        return strings.Repeat("*", len(apiKey))
16
3x
    }
17

18
13x
    return apiKey[:4] + strings.Repeat("*", len(apiKey)-8) + apiKey[len(apiKey)-4:]
19
}
20


			
quizapp internal utils validation.go
54.7%
Statements
29/53
1
package contextutils
2

3
import (
4
    "context"
5
    "time"
6

7
    "quizapp/internal/models"
8
)
9

10
// ParseDateInUserTimezone parses a YYYY-MM-DD date string in the user's timezone.
11
// The userLookup function is injected to fetch the user (to avoid tight coupling and enable testing).
12
// Returns the parsed time (in the location), the effective timezone name (or "UTC" on fallback), and an error.
13
// If the date format is invalid, the returned error will be wrapped with the message "invalid date format".
14
func ParseDateInUserTimezone(
15
    ctx context.Context,
16
    userID int,
17
    dateStr string,
18
    userLookup func(context.Context, int) (*models.User, error),
19
1x
) (time.Time, string, error) {
20
1x
    user, err := userLookup(ctx, userID)
21
1x
    if err != nil {
22
        return time.Time{}, "", err
23
    }
24

25
1x
    timezone := "UTC"
26
1x
    if user != nil && user.Timezone.Valid && user.Timezone.String != "" {
27
1x
        timezone = user.Timezone.String
28
1x
    }
29

30
1x
    loc, err := time.LoadLocation(timezone)
31
1x
    if err != nil {
32
        // Fallback to UTC if invalid timezone
33
        loc = time.UTC
34
        timezone = "UTC"
35
    }
36

37
1x
    date, err := time.ParseInLocation("2006-01-02", dateStr, loc)
38
1x
    if err != nil {
39
        return time.Time{}, timezone, WrapError(err, "invalid date format")
40
    }
41

42
1x
    return date, timezone, nil
43
}
44

45
// ConvertTimeToUserLocation converts the provided time to the user's timezone.
46
// Returns the converted time and the effective timezone name (or "UTC" on fallback).
47
func ConvertTimeToUserLocation(
48
    ctx context.Context,
49
    userID int,
50
    t time.Time,
51
    userLookup func(context.Context, int) (*models.User, error),
52
) (time.Time, string, error) {
53
    user, err := userLookup(ctx, userID)
54
    if err != nil {
55
        return time.Time{}, "", err
56
    }
57

58
    timezone := "UTC"
59
    if user != nil && user.Timezone.Valid && user.Timezone.String != "" {
60
        timezone = user.Timezone.String
61
    }
62

63
    loc, err := time.LoadLocation(timezone)
64
    if err != nil {
65
        loc = time.UTC
66
        timezone = "UTC"
67
    }
68

69
    return t.In(loc), timezone, nil
70
}
71

72
// FormatTimeInUserTimezone formats the provided time in the user's timezone using the given layout.
73
// Returns the formatted string and the effective timezone name.
74
func FormatTimeInUserTimezone(
75
    ctx context.Context,
76
    userID int,
77
    t time.Time,
78
    layout string,
79
    userLookup func(context.Context, int) (*models.User, error),
80
1x
) (string, string, error) {
81
1x
    // If the stored timestamp is exactly midnight UTC with zero nanoseconds,
82
1x
    // it may be a date-only value (missing timezone). We only treat it as
83
1x
    // missing if the user has a configured timezone that is not UTC.
84
1x
    if t.Location() == time.UTC && t.Hour() == 0 && t.Minute() == 0 && t.Second() == 0 && t.Nanosecond() == 0 {
85
1x
        if userLookup != nil {
86
1x
            if u, err := userLookup(ctx, userID); err == nil && u != nil && u.Timezone.Valid && u.Timezone.String != "" && u.Timezone.String != "UTC" {
87
1x
                return "", "", ErrTimestampMissingTimezone
88
1x
            }
89
        }
90
    }
91

92
    tt, tz, err := ConvertTimeToUserLocation(ctx, userID, t, userLookup)
93
    if err != nil {
94
        return "", tz, err
95
    }
96
    res := tt.Format(layout)
97
    return res, tz, nil
98
}
99

100
// UserLocalDayRange returns the UTC start and end timestamps that cover the
101
// last `days` calendar days for the given user in their configured timezone.
102
// The range is [startUTC, endUTC) where startUTC is the start of the earliest
103
// local day at 00:00 and endUTC is the start of the day after "today" at 00:00
104
// in UTC. The userLookup function is used to fetch the user's timezone.
105
2x
func UserLocalDayRange(ctx context.Context, userID, days int, userLookup func(context.Context, int) (*models.User, error)) (time.Time, time.Time, string, error) {
106
2x
    if days <= 0 {
107
        days = 1
108
    }
109
2x
    user, err := userLookup(ctx, userID)
110
2x
    if err != nil {
111
        return time.Time{}, time.Time{}, "", err
112
    }
113

114
2x
    timezone := "UTC"
115
2x
    if user != nil && user.Timezone.Valid && user.Timezone.String != "" {
116
1x
        timezone = user.Timezone.String
117
1x
    }
118

119
2x
    loc, err := time.LoadLocation(timezone)
120
2x
    if err != nil {
121
        loc = time.UTC
122
        timezone = "UTC"
123
    }
124

125
2x
    now := time.Now().In(loc)
126
2x
    today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc)
127
2x
    startLocal := today.AddDate(0, 0, -(days - 1))
128
2x
    // start of the day after today
129
2x
    endLocal := today.Add(24 * time.Hour)
130
2x

131
2x
    startUTC := startLocal.UTC()
132
2x
    endUTC := endLocal.UTC()
133
2x
    return startUTC, endUTC, timezone, nil
134
}
135


			
quizapp internal utils validation.go
100.0%
Statements
1/1
1
package contextutils
2

3
import (
4
    "github.com/go-playground/validator/v10"
5
)
6

7
var validate = validator.New()
8

9
// IsValidEmail checks if an email address is valid using go-playground/validator
10
14x
func IsValidEmail(email string) bool {
11
14x
    return validate.Var(email, "email") == nil
12
14x
}
13


			
quizapp internal worker
68.5%
Statements
639/933
worker.go
68.5%
639/933
quizapp internal worker worker.go
68.5%
Statements
639/933
1
// Package worker contains the background worker responsible for generating
2
// and maintaining daily question assignments, scheduling generation jobs,
3
// and reporting worker health. The worker runs independently of HTTP
4
// request handling and interacts with the database, AI providers, and
5
// other internal services to keep question queues primed for users.
6
package worker
7

8
import (
9
    "context"
10
    "database/sql"
11
    "encoding/json"
12
    "errors"
13
    "fmt"
14
    "math"
15
    "os"
16
    "strconv"
17
    "strings"
18
    "sync"
19
    "time"
20

21
    "quizapp/internal/config"
22
    "quizapp/internal/models"
23
    "quizapp/internal/observability"
24
    "quizapp/internal/services"
25
    "quizapp/internal/services/mailer"
26
    contextutils "quizapp/internal/utils"
27

28
    "go.opentelemetry.io/otel"
29
    "go.opentelemetry.io/otel/attribute"
30
    "go.opentelemetry.io/otel/trace"
31
)
32

33
// Status represents the current state of the worker
34
type Status struct {
35
    IsRunning       bool      `json:"is_running"`
36
    IsPaused        bool      `json:"is_paused"`
37
    CurrentActivity string    `json:"current_activity,omitempty"`
38
    LastRunStart    time.Time `json:"last_run_start"`
39
    LastRunFinish   time.Time `json:"last_run_finish"`
40
    LastRunError    string    `json:"last_run_error,omitempty"`
41
    NextRun         time.Time `json:"next_run"`
42
}
43

44
// RunRecord tracks individual worker runs
45
type RunRecord struct {
46
    StartTime time.Time     `json:"start_time"`
47
    EndTime   time.Time     `json:"end_time"`
48
    Duration  time.Duration `json:"duration"`
49
    Status    string        `json:"status"` // Success, Failure
50
    Details   string        `json:"details"`
51
}
52

53
// ActivityLog represents a single activity log entry
54
type ActivityLog struct {
55
    Timestamp time.Time `json:"timestamp"`
56
    Level     string    `json:"level"` // INFO, WARN, ERROR
57
    Message   string    `json:"message"`
58
    UserID    *int      `json:"user_id,omitempty"`
59
    Username  *string   `json:"username,omitempty"`
60
}
61

62
// UserFailureInfo tracks failure information for exponential backoff
63
type UserFailureInfo struct {
64
    ConsecutiveFailures int
65
    LastFailureTime     time.Time
66
    NextRetryTime       time.Time
67
}
68

69
// Config holds worker-specific configuration
70
type Config struct {
71
    StartWorkerPaused bool
72
    DailyHorizonDays  int
73
}
74

75
// Worker manages AI question generation in the background
76
type Worker struct {
77
    userService            services.UserServiceInterface
78
    questionService        services.QuestionServiceInterface
79
    aiService              services.AIServiceInterface
80
    learningService        services.LearningServiceInterface
81
    workerService          services.WorkerServiceInterface
82
    dailyQuestionService   services.DailyQuestionServiceInterface
83
    wordOfTheDayService    services.WordOfTheDayServiceInterface
84
    storyService           services.StoryServiceInterface
85
    emailService           mailer.Mailer
86
    hintService            services.GenerationHintServiceInterface
87
    translationCacheRepo   services.TranslationCacheRepository
88
    instance               string
89
    status                 Status
90
    history                []RunRecord
91
    activityLogs           []ActivityLog // Circular buffer for recent activity logs
92
    mu                     sync.RWMutex
93
    manualTrigger          chan bool
94
    cfg                    *config.Config
95
    workerCfg              Config
96
    logger                 *observability.Logger
97
    lastTranslationCleanup time.Time // Track last translation cache cleanup
98
    translationCleanupMu   sync.RWMutex
99

100
    // Track failures for exponential backoff
101
    userFailures map[int]*UserFailureInfo // userID -> failure info
102
    failureMu    sync.RWMutex             // mutex for failure tracking
103

104
    // Time function for testing - defaults to time.Now
105
    timeNow func() time.Time
106
    cancel  context.CancelFunc // Added for cleanup
107
}
108

109
// cleanupTranslationCache removes expired translation cache entries once per day
110
3x
func (w *Worker) cleanupTranslationCache(ctx context.Context) error {
111
3x
    ctx, span := otel.Tracer("worker").Start(ctx, "cleanupTranslationCache",
112
3x
        trace.WithAttributes(
113
3x
            attribute.String("worker.instance", w.instance),
114
3x
        ),
115
3x
    )
116
3x
    defer span.End()
117
3x

118
3x
    // Check if we've already cleaned up today
119
3x
    w.translationCleanupMu.Lock()
120
3x
    lastCleanup := w.lastTranslationCleanup
121
3x
    w.translationCleanupMu.Unlock()
122
3x

123
3x
    now := w.timeNow()
124
3x

125
3x
    // Only cleanup once per day (check if last cleanup was on a different day)
126
3x
    if !lastCleanup.IsZero() {
127
        lastCleanupDay := lastCleanup.Truncate(24 * time.Hour)
128
        todayDay := now.Truncate(24 * time.Hour)
129

130
        if lastCleanupDay.Equal(todayDay) {
131
            // Already cleaned up today
132
            span.SetAttributes(
133
                attribute.Bool("cleanup.skipped", true),
134
                attribute.String("cleanup.last_run", lastCleanup.Format(time.RFC3339)),
135
            )
136
            return nil
137
        }
138
    }
139

140
3x
    w.logger.Info(ctx, "Cleaning up expired translation cache entries", map[string]interface{}{
141
3x
        "last_cleanup": lastCleanup,
142
3x
    })
143
3x

144
3x
    count, err := w.translationCacheRepo.CleanupExpiredTranslations(ctx)
145
3x
    if err != nil {
146
        span.RecordError(err)
147
        span.SetAttributes(attribute.Bool("cleanup.success", false))
148
        return contextutils.WrapError(err, "failed to cleanup expired translation cache entries")
149
    }
150

151
    // Update last cleanup time
152
3x
    w.translationCleanupMu.Lock()
153
3x
    w.lastTranslationCleanup = now
154
3x
    w.translationCleanupMu.Unlock()
155
3x

156
3x
    span.SetAttributes(
157
3x
        attribute.Bool("cleanup.success", true),
158
3x
        attribute.Int64("cleanup.deleted_count", count),
159
3x
    )
160
3x

161
3x
    w.logger.Info(ctx, "Translation cache cleanup completed", map[string]interface{}{
162
3x
        "deleted_count": count,
163
3x
        "instance":      w.instance,
164
3x
    })
165
3x

166
3x
    return nil
167
}
168

169
// checkForDailyReminders checks if any users need daily reminder emails
170
11x
func (w *Worker) checkForDailyReminders(ctx context.Context) error {
171
11x
    ctx, span := otel.Tracer("worker").Start(ctx, "checkForDailyReminders",
172
11x
        trace.WithAttributes(
173
11x
            attribute.String("worker.instance", w.instance),
174
11x
            attribute.Bool("email.daily_reminder.enabled", w.cfg.Email.DailyReminder.Enabled),
175
11x
            attribute.Int("email.daily_reminder.hour", w.cfg.Email.DailyReminder.Hour),
176
11x
            attribute.Bool("email.enabled", w.cfg.Email.Enabled),
177
11x
        ),
178
11x
    )
179
11x
    defer span.End()
180
11x

181
11x
    if !w.cfg.Email.DailyReminder.Enabled {
182
1x
        w.logger.Info(ctx, "Daily reminders disabled, skipping", nil)
183
1x
        return nil
184
1x
    }
185

186
    // Get current time in UTC
187
9x
    now := w.timeNow().UTC()
188
9x
    currentHour := now.Hour()
189
9x

190
9x
    // Check if it's time to send reminders (default: 9 AM)
191
9x
    reminderHour := w.cfg.Email.DailyReminder.Hour
192
9x
    if currentHour != reminderHour {
193
5x
        span.SetAttributes(
194
5x
            attribute.Int("check.current_hour", currentHour),
195
5x
            attribute.Int("check.reminder_hour", reminderHour),
196
5x
            attribute.Bool("check.should_send", false),
197
5x
            attribute.String("check.reason", "wrong_hour"),
198
5x
        )
199
5x
        return nil
200
5x
    }
201

202
2x
    span.SetAttributes(
203
2x
        attribute.Int("check.current_hour", currentHour),
204
2x
        attribute.Int("check.reminder_hour", reminderHour),
205
2x
        attribute.Bool("check.should_send", true),
206
2x
    )
207
2x

208
2x
    w.logger.Info(ctx, "Checking for users needing daily reminders", map[string]interface{}{
209
2x
        "reminder_hour": reminderHour,
210
2x
    })
211
2x

212
2x
    // Get users who need daily reminders
213
2x
    users, err := w.getUsersNeedingDailyReminders(ctx)
214
2x
    if err != nil {
215
        span.RecordError(err)
216
        span.SetAttributes(
217
            attribute.Int("users.total", 0),
218
            attribute.Int("users.eligible", 0),
219
            attribute.Int("reminders.sent", 0),
220
        )
221
        w.logger.Error(ctx, "Failed to get users needing daily reminders", err, nil)
222
        return contextutils.WrapError(err, "failed to get users needing daily reminders")
223
    }
224

225
2x
    span.SetAttributes(
226
2x
        attribute.Int("users.total", len(users)),
227
2x
    )
228
2x

229
2x
    remindersSent := 0
230
2x
    failedReminders := 0
231
2x

232
2x
    for _, user := range users {
233
1x
        // Record the sent notification
234
1x
        subject := "Time for your daily quiz! ð"
235
1x
        status := "sent"
236
1x
        errorMsg := ""
237
1x

238
1x
        if err := w.emailService.SendDailyReminder(ctx, &user); err != nil {
239
            failedReminders++
240
            status = "failed"
241
            errorMsg = err.Error()
242
            w.logger.Error(ctx, "Failed to send daily reminder", err, map[string]interface{}{
243
                "user_id": user.ID,
244
                "email":   user.Email.String,
245
            })
246
        } else {
247
1x
            remindersSent++
248
1x
        }
249

250
        // Record the sent notification in the database
251
1x
        if err := w.emailService.RecordSentNotification(ctx, user.ID, "daily_reminder", subject, "daily_reminder", status, errorMsg); err != nil {
252
            w.logger.Error(ctx, "Failed to record sent notification", err, map[string]interface{}{
253
                "user_id": user.ID,
254
            })
255
        }
256

257
        // Update the last reminder sent timestamp for this user
258
1x
        if err := w.learningService.UpdateLastDailyReminderSent(ctx, user.ID); err != nil {
259
            w.logger.Error(ctx, "Failed to update last daily reminder sent timestamp", err, map[string]interface{}{
260
                "user_id": user.ID,
261
            })
262
            // Don't count this as a failed reminder since the email was sent successfully
263
        }
264
    }
265

266
2x
    span.SetAttributes(
267
2x
        attribute.Int("users.eligible", len(users)),
268
2x
        attribute.Int("reminders.sent", remindersSent),
269
2x
        attribute.Int("reminders.failed", failedReminders),
270
2x
        attribute.Float64("reminders.success_rate", float64(remindersSent)/float64(len(users))),
271
2x
    )
272
2x

273
2x
    w.logger.Info(ctx, "Daily reminders processed", map[string]interface{}{
274
2x
        "total_users":    len(users),
275
2x
        "reminders_sent": remindersSent,
276
2x
        "reminder_hour":  reminderHour,
277
2x
    })
278
2x

279
2x
    return nil
280
}
281

282
// getUsersNeedingDailyReminders returns users who should receive daily reminders
283
3x
func (w *Worker) getUsersNeedingDailyReminders(ctx context.Context) ([]models.User, error) {
284
3x
    ctx, span := otel.Tracer("worker").Start(ctx, "getUsersNeedingDailyReminders")
285
3x
    defer span.End()
286
3x

287
3x
    // Get all users and filter for those with email addresses and daily reminders enabled
288
3x
    users, err := w.userService.GetAllUsers(ctx)
289
3x
    if err != nil {
290
        span.RecordError(err)
291
        return nil, contextutils.WrapError(err, "failed to get users")
292
    }
293

294
3x
    var eligibleUsers []models.User
295
3x
    today := w.timeNow().UTC().Format("2006-01-02")
296
3x

297
3x
    for _, user := range users {
298
9x
        // Check if user has email address
299
9x
        if !user.Email.Valid || user.Email.String == "" {
300
2x
            continue
301
        }
302

303
        // Get user's learning preferences to check daily reminder setting
304
7x
        prefs, err := w.learningService.GetUserLearningPreferences(ctx, user.ID)
305
7x
        if err != nil {
306
            w.logger.Warn(ctx, "Failed to get user learning preferences for daily reminder check", map[string]interface{}{
307
                "user_id":  user.ID,
308
                "username": user.Username,
309
                "error":    err.Error(),
310
            })
311
            continue
312
        }
313

314
        // Check if daily reminders are enabled for this user
315
7x
        if prefs == nil || !prefs.DailyReminderEnabled {
316
3x
            continue
317
        }
318

319
        // Check if we've already sent a reminder today
320
4x
        if prefs.LastDailyReminderSent != nil {
321
2x
            lastReminderDate := prefs.LastDailyReminderSent.Format("2006-01-02")
322
2x
            if lastReminderDate == today {
323
2x
                continue
324
            }
325
        }
326

327
2x
        eligibleUsers = append(eligibleUsers, user)
328
    }
329

330
3x
    w.logger.Info(ctx, "Found users eligible for daily reminders", map[string]interface{}{
331
3x
        "total_users":    len(users),
332
3x
        "eligible_users": len(eligibleUsers),
333
3x
    })
334
3x

335
3x
    return eligibleUsers, nil
336
}
337

338
// checkForDailyQuestionAssignments assigns daily questions to all eligible users
339
// This runs independently of email reminders to ensure users get daily questions
340
// even if they have email reminders disabled
341
17x
func (w *Worker) checkForDailyQuestionAssignments(ctx context.Context) error {
342
17x
    ctx, span := observability.TraceWorkerFunction(ctx, "check_for_daily_question_assignments",
343
17x
        attribute.String("worker.instance", w.instance),
344
17x
    )
345
17x
    defer observability.FinishSpan(span, nil)
346
17x

347
17x
    w.logger.Info(ctx, "Checking for daily question assignments", map[string]interface{}{
348
17x
        "instance": w.instance,
349
17x
    })
350
17x

351
17x
    // Get users who are eligible for daily questions
352
17x
    users, err := w.getUsersEligibleForDailyQuestions(ctx)
353
17x
    if err != nil {
354
        span.RecordError(err)
355
        w.logger.Error(ctx, "Failed to get users eligible for daily questions", err, nil)
356
        return contextutils.WrapError(err, "failed to get users eligible for daily questions")
357
    }
358

359
17x
    if len(users) == 0 {
360
5x
        w.logger.Info(ctx, "No users eligible for daily question assignments", map[string]interface{}{
361
5x
            "instance": w.instance,
362
5x
        })
363
5x
        return nil
364
5x
    }
365

366
12x
    span.SetAttributes(
367
12x
        attribute.Int("users.total", len(users)),
368
12x
    )
369
12x

370
12x
    successfulAssignments := 0
371
12x
    failedAssignments := 0
372
12x

373
12x
    for _, user := range users {
374
16x
        // Get user's timezone, default to UTC if not set
375
16x
        timezone := "UTC"
376
16x
        if user.Timezone.Valid && user.Timezone.String != "" {
377
2x
            timezone = user.Timezone.String
378
2x
        }
379

380
        // Get today's date in the user's timezone
381
16x
        loc, err := time.LoadLocation(timezone)
382
16x
        if err != nil {
383
            w.logger.Warn(ctx, "Invalid timezone for user, using UTC", map[string]interface{}{
384
                "user_id":  user.ID,
385
                "username": user.Username,
386
                "timezone": timezone,
387
                "error":    err.Error(),
388
            })
389
            loc = time.UTC
390
        }
391

392
        // Get today's date in the user's timezone
393
16x
        now := w.timeNow().In(loc)
394
16x
        today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc)
395
16x

396
16x
        // Assign daily questions for dates in [today .. today+N]
397
16x
        horizon := w.workerCfg.DailyHorizonDays
398
16x
        if horizon <= 0 {
399
            // default to 2 days ahead when misconfigured or not set
400
            horizon = 2
401
        }
402

403
        // Ensure the worker horizon covers the configured avoid window so
404
        // that when future assignments are removed (e.g., after a correct
405
        // submission) the worker run will top up missing slots. Use server
406
        // config as the source of truth for the avoid window.
407
16x
        avoidDays := 7
408
16x
        if w.cfg != nil && w.cfg.Server.DailyRepeatAvoidDays > 0 {
409
2x
            avoidDays = w.cfg.Server.DailyRepeatAvoidDays
410
2x
        }
411
16x
        if horizon < avoidDays {
412
16x
            w.logger.Info(ctx, "Extending worker daily horizon to cover daily repeat avoid window", map[string]interface{}{
413
16x
                "old_horizon": horizon,
414
16x
                "new_horizon": avoidDays,
415
16x
                "user_id":     user.ID,
416
16x
            })
417
16x
            horizon = avoidDays
418
16x
        }
419
16x
        for d := 0; d <= horizon; d++ {
420
128x
            target := today.AddDate(0, 0, d)
421
128x
            // Assign daily questions for target date in user's timezone
422
128x
            if err := w.dailyQuestionService.AssignDailyQuestions(ctx, user.ID, target); err != nil {
423
16x
                failedAssignments++
424
16x
                w.logger.Error(ctx, "Failed to assign daily questions", err, map[string]interface{}{
425
16x
                    "user_id":  user.ID,
426
16x
                    "username": user.Username,
427
16x
                    "timezone": timezone,
428
16x
                    "date":     target.Format("2006-01-02"),
429
16x
                })
430
16x
            } else {
431
96x
                successfulAssignments++
432
96x
            }
433
        }
434
    }
435

436
12x
    span.SetAttributes(
437
12x
        attribute.Int("assignments.successful", successfulAssignments),
438
12x
        attribute.Int("assignments.failed", failedAssignments),
439
12x
    )
440
12x

441
12x
    return nil
442
}
443

444
// getUsersEligibleForDailyQuestions returns users who should receive daily questions
445
// This is independent of email reminder preferences
446
25x
func (w *Worker) getUsersEligibleForDailyQuestions(ctx context.Context) ([]models.User, error) {
447
25x
    ctx, span := otel.Tracer("worker").Start(ctx, "getUsersEligibleForDailyQuestions")
448
25x
    defer span.End()
449
25x

450
25x
    // Get all users
451
25x
    users, err := w.userService.GetAllUsers(ctx)
452
25x
    if err != nil {
453
1x
        span.RecordError(err)
454
1x
        return nil, contextutils.WrapError(err, "failed to get users")
455
1x
    }
456

457
23x
    var eligibleUsers []models.User
458
23x

459
23x
    for _, user := range users {
460
35x
        // Check if user has language and level preferences set
461
35x
        if !user.PreferredLanguage.Valid || user.PreferredLanguage.String == "" {
462
3x
            w.logger.Debug(ctx, "User missing preferred language, skipping daily question assignment", map[string]interface{}{
463
3x
                "user_id":  user.ID,
464
3x
                "username": user.Username,
465
3x
            })
466
3x
            continue
467
        }
468

469
29x
        if !user.CurrentLevel.Valid || user.CurrentLevel.String == "" {
470
3x
            w.logger.Debug(ctx, "User missing current level, skipping daily question assignment", map[string]interface{}{
471
3x
                "user_id":  user.ID,
472
3x
                "username": user.Username,
473
3x
            })
474
3x
            continue
475
        }
476

477
        // USers with AI disabled are not eligible for daily questions
478
23x
        if !user.AIEnabled.Valid || !user.AIEnabled.Bool {
479
1x
            w.logger.Debug(ctx, "User has AI disabled, skipping daily question assignment", map[string]interface{}{
480
1x
                "user_id":  user.ID,
481
1x
                "username": user.Username,
482
1x
            })
483
1x
            continue
484
        }
485

486
22x
        eligibleUsers = append(eligibleUsers, user)
487
    }
488

489
23x
    w.logger.Info(ctx, "Found users eligible for daily questions", map[string]interface{}{
490
23x
        "total_users":    len(users),
491
23x
        "eligible_users": len(eligibleUsers),
492
23x
    })
493
23x

494
23x
    return eligibleUsers, nil
495
}
496

497
// checkForWordOfTheDayAssignments assigns word of the day to all eligible users
498
3x
func (w *Worker) checkForWordOfTheDayAssignments(ctx context.Context) error {
499
3x
    ctx, span := observability.TraceWorkerFunction(ctx, "check_for_word_of_the_day_assignments",
500
3x
        attribute.String("worker.instance", w.instance),
501
3x
    )
502
3x
    defer observability.FinishSpan(span, nil)
503
3x

504
3x
    w.logger.Info(ctx, "Checking for word of the day assignments", map[string]interface{}{
505
3x
        "instance": w.instance,
506
3x
    })
507
3x

508
3x
    // Get users who are eligible for word of the day
509
3x
    users, err := w.getUsersEligibleForWordOfTheDay(ctx)
510
3x
    if err != nil {
511
        span.RecordError(err)
512
        w.logger.Error(ctx, "Failed to get users eligible for word of the day", err, nil)
513
        return contextutils.WrapError(err, "failed to get users eligible for word of the day")
514
    }
515

516
3x
    if len(users) == 0 {
517
3x
        w.logger.Info(ctx, "No users eligible for word of the day assignments", map[string]interface{}{
518
3x
            "instance": w.instance,
519
3x
        })
520
3x
        return nil
521
3x
    }
522

523
    span.SetAttributes(
524
        attribute.Int("users.total", len(users)),
525
    )
526

527
    successfulAssignments := 0
528
    failedAssignments := 0
529

530
    for _, user := range users {
531
        // Get user's timezone, default to UTC if not set
532
        timezone := "UTC"
533
        if user.Timezone.Valid && user.Timezone.String != "" {
534
            timezone = user.Timezone.String
535
        }
536

537
        // Get today's date in the user's timezone
538
        loc, err := time.LoadLocation(timezone)
539
        if err != nil {
540
            w.logger.Warn(ctx, "Invalid timezone for user, using UTC", map[string]interface{}{
541
                "user_id":  user.ID,
542
                "username": user.Username,
543
                "timezone": timezone,
544
                "error":    err.Error(),
545
            })
546
            loc = time.UTC
547
        }
548

549
        // Get today's date in the user's timezone
550
        now := w.timeNow().In(loc)
551
        today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc)
552

553
        // Idempotent: fetch existing or create if missing
554
        _, err = w.wordOfTheDayService.GetWordOfTheDay(ctx, user.ID, today)
555
        if err != nil {
556
            // Treat no-available-word as a normal condition
557
            if errors.Is(err, services.ErrNoSuitableWord) {
558
                w.logger.Info(ctx, "No suitable word available for user today", map[string]interface{}{
559
                    "user_id":  user.ID,
560
                    "username": user.Username,
561
                    "timezone": timezone,
562
                    "date":     today.Format("2006-01-02"),
563
                })
564
                continue
565
            }
566
            failedAssignments++
567
            w.logger.Error(ctx, "Failed to assign word of the day", err, map[string]interface{}{
568
                "user_id":  user.ID,
569
                "username": user.Username,
570
                "timezone": timezone,
571
                "date":     today.Format("2006-01-02"),
572
            })
573
        } else {
574
            successfulAssignments++
575
        }
576
    }
577

578
    span.SetAttributes(
579
        attribute.Int("assignments.successful", successfulAssignments),
580
        attribute.Int("assignments.failed", failedAssignments),
581
    )
582

583
    return nil
584
}
585

586
// getUsersEligibleForWordOfTheDay returns users who should receive word of the day
587
3x
func (w *Worker) getUsersEligibleForWordOfTheDay(ctx context.Context) ([]models.User, error) {
588
3x
    ctx, span := otel.Tracer("worker").Start(ctx, "getUsersEligibleForWordOfTheDay")
589
3x
    defer span.End()
590
3x

591
3x
    // Get all users
592
3x
    users, err := w.userService.GetAllUsers(ctx)
593
3x
    if err != nil {
594
        span.RecordError(err)
595
        return nil, contextutils.WrapError(err, "failed to get users")
596
    }
597

598
3x
    var eligibleUsers []models.User
599
3x

600
3x
    for _, user := range users {
601
1x
        // Check if user has language and level preferences set
602
1x
        if !user.PreferredLanguage.Valid || user.PreferredLanguage.String == "" {
603
            continue
604
        }
605

606
1x
        if !user.CurrentLevel.Valid || user.CurrentLevel.String == "" {
607
            continue
608
        }
609

610
        // Skip users with AI disabled
611
1x
        if !user.AIEnabled.Valid || !user.AIEnabled.Bool {
612
1x
            continue
613
        }
614

615
        eligibleUsers = append(eligibleUsers, user)
616
    }
617

618
3x
    w.logger.Info(ctx, "Found users eligible for word of the day", map[string]interface{}{
619
3x
        "total_users":    len(users),
620
3x
        "eligible_users": len(eligibleUsers),
621
3x
    })
622
3x

623
3x
    return eligibleUsers, nil
624
}
625

626
// checkForWordOfTheDayEmails sends word of the day emails to eligible users
627
3x
func (w *Worker) checkForWordOfTheDayEmails(ctx context.Context) error {
628
3x
    ctx, span := observability.TraceWorkerFunction(ctx, "check_for_word_of_the_day_emails",
629
3x
        attribute.String("worker.instance", w.instance),
630
3x
    )
631
3x
    defer observability.FinishSpan(span, nil)
632
3x

633
3x
    if !w.cfg.Email.DailyReminder.Enabled {
634
        w.logger.Info(ctx, "Email disabled, skipping word of the day emails", nil)
635
        return nil
636
    }
637

638
    // Get current time in UTC
639
3x
    now := w.timeNow().UTC()
640
3x
    currentHour := now.Hour()
641
3x

642
3x
    // Send word of the day emails at the same hour as daily reminders (default: 9 AM)
643
3x
    reminderHour := w.cfg.Email.DailyReminder.Hour
644
3x
    if currentHour != reminderHour {
645
3x
        return nil
646
3x
    }
647

648
    // Get users who should receive word of the day emails
649
    users, err := w.getUsersNeedingWordOfTheDayEmails(ctx)
650
    if err != nil {
651
        span.RecordError(err)
652
        return contextutils.WrapError(err, "failed to get users needing word of the day emails")
653
    }
654

655
    span.SetAttributes(
656
        attribute.Int("users.total", len(users)),
657
    )
658

659
    emailsSent := 0
660
    failedEmails := 0
661

662
    for _, user := range users {
663
        // Get user's timezone
664
        timezone := "UTC"
665
        if user.Timezone.Valid && user.Timezone.String != "" {
666
            timezone = user.Timezone.String
667
        }
668

669
        loc, err := time.LoadLocation(timezone)
670
        if err != nil {
671
            loc = time.UTC
672
        }
673

674
        now := w.timeNow().In(loc)
675
        today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc)
676

677
        // Get word of the day for today
678
        word, err := w.wordOfTheDayService.GetWordOfTheDay(ctx, user.ID, today)
679
        if err != nil {
680
            failedEmails++
681
            w.logger.Error(ctx, "Failed to get word of the day for email", err, map[string]interface{}{
682
                "user_id":  user.ID,
683
                "username": user.Username,
684
            })
685
            continue
686
        }
687

688
        if word == nil {
689
            // No word available, skip
690
            continue
691
        }
692

693
        // Send email (convert mailer.Mailer to services.EmailServiceInterface)
694
        emailSvc, ok := w.emailService.(services.EmailServiceInterface)
695
        if !ok {
696
            w.logger.Warn(ctx, "Email service does not support word of the day emails", map[string]interface{}{
697
                "user_id": user.ID,
698
            })
699
            continue
700
        }
701

702
        if err := emailSvc.SendWordOfTheDayEmail(ctx, user.ID, today, word); err != nil {
703
            failedEmails++
704
            w.logger.Error(ctx, "Failed to send word of the day email", err, map[string]interface{}{
705
                "user_id":  user.ID,
706
                "username": user.Username,
707
            })
708
        } else {
709
            emailsSent++
710
        }
711
    }
712

713
    span.SetAttributes(
714
        attribute.Int("emails.sent", emailsSent),
715
        attribute.Int("emails.failed", failedEmails),
716
    )
717

718
    return nil
719
}
720

721
// getUsersNeedingWordOfTheDayEmails returns users who should receive word of the day emails
722
func (w *Worker) getUsersNeedingWordOfTheDayEmails(ctx context.Context) ([]models.User, error) {
723
    ctx, span := otel.Tracer("worker").Start(ctx, "getUsersNeedingWordOfTheDayEmails")
724
    defer span.End()
725

726
    // Get all users
727
    users, err := w.userService.GetAllUsers(ctx)
728
    if err != nil {
729
        span.RecordError(err)
730
        return nil, contextutils.WrapError(err, "failed to get users")
731
    }
732

733
    var eligibleUsers []models.User
734

735
    for _, user := range users {
736
        // Check if user has email address
737
        if !user.Email.Valid || user.Email.String == "" {
738
            continue
739
        }
740

741
        // Check if word of the day emails are enabled for this user
742
        if !user.WordOfDayEmailEnabled.Bool {
743
            continue
744
        }
745

746
        eligibleUsers = append(eligibleUsers, user)
747
    }
748

749
    w.logger.Info(ctx, "Found users eligible for word of the day emails", map[string]interface{}{
750
        "total_users":    len(users),
751
        "eligible_users": len(eligibleUsers),
752
    })
753

754
    return eligibleUsers, nil
755
}
756

757
// NewWorker creates a new Worker instance
758
135x
func NewWorker(userService services.UserServiceInterface, questionService services.QuestionServiceInterface, aiService services.AIServiceInterface, learningService services.LearningServiceInterface, workerService services.WorkerServiceInterface, dailyQuestionService services.DailyQuestionServiceInterface, wordOfTheDayService services.WordOfTheDayServiceInterface, storyService services.StoryServiceInterface, emailService mailer.Mailer, hintService services.GenerationHintServiceInterface, translationCacheRepo services.TranslationCacheRepository, instance string, cfg *config.Config, logger *observability.Logger) *Worker {
759
135x
    if instance == "" {
760
1x
        instance = "default"
761
1x
    }
762

763
135x
    ctx, cancel := context.WithCancel(context.Background())
764
135x

765
135x
    // Prefer value from config file when set (>0). If not set, default to 1.
766
135x
    dailyHorizon := cfg.Server.DailyHorizonDays
767
135x
    if dailyHorizon <= 0 {
768
57x
        dailyHorizon = 1
769
57x
    }
770

771
135x
    w := &Worker{
772
135x
        userService:          userService,
773
135x
        questionService:      questionService,
774
135x
        aiService:            aiService,
775
135x
        learningService:      learningService,
776
135x
        workerService:        workerService,
777
135x
        dailyQuestionService: dailyQuestionService,
778
135x
        wordOfTheDayService:  wordOfTheDayService,
779
135x
        storyService:         storyService,
780
135x
        emailService:         emailService,
781
135x
        hintService:          hintService,
782
135x
        translationCacheRepo: translationCacheRepo,
783
135x
        instance:             instance,
784
135x
        status:               Status{IsRunning: false, CurrentActivity: "Initialized"},
785
135x
        history:              make([]RunRecord, 0, cfg.Server.MaxHistory),
786
135x
        activityLogs:         make([]ActivityLog, 0, cfg.Server.MaxActivityLogs),
787
135x
        manualTrigger:        make(chan bool, 1),
788
135x
        cfg:                  cfg,
789
135x
        workerCfg:            Config{StartWorkerPaused: getEnvBool("WORKER_START_PAUSED", false), DailyHorizonDays: dailyHorizon},
790
135x
        logger:               logger,
791
135x
        userFailures:         make(map[int]*UserFailureInfo),
792
135x
        timeNow:              time.Now, // Default to real time
793
135x
    }
794
135x

795
135x
    // Handle startup pause if configured
796
135x
    if w.workerCfg.StartWorkerPaused {
797
        w.handleStartupPause(ctx)
798
    }
799

800
    // Store cancel function for cleanup
801
135x
    w.cancel = cancel
802
135x

803
135x
    return w
804
}
805

806
// getEnvBool is a helper function to get boolean environment variables
807
143x
func getEnvBool(key string, defaultValue bool) bool {
808
143x
    valStr := os.Getenv(key)
809
143x
    if valStr == "" {
810
137x
        return defaultValue
811
137x
    }
812
3x
    val, err := strconv.ParseBool(valStr)
813
3x
    if err != nil {
814
1x
        return defaultValue
815
1x
    }
816
2x
    return val
817
}
818

819
// Start begins the worker's background processing loop
820
1x
func (w *Worker) Start(ctx context.Context) {
821
1x
    w.status.IsRunning = true
822
1x
    w.updateDatabaseStatus(ctx)
823
1x
    w.handleStartupPause(ctx)
824
1x

825
1x
    // Start heartbeat goroutine
826
1x
    go w.heartbeatLoop(ctx)
827
1x

828
1x
    // Main worker loop
829
1x
    ticker := time.NewTicker(config.WorkerHeartbeatInterval)
830
1x
    defer ticker.Stop()
831
1x

832
1x
    initialStatus := w.getInitialWorkerStatus(ctx)
833
1x

834
1x
    w.logger.Info(ctx, "Worker started", map[string]any{
835
1x
        "instance": w.instance,
836
1x
        "status":   initialStatus,
837
1x
    })
838
1x
    w.logActivity(ctx, "INFO", fmt.Sprintf("Worker %s started (%s)", w.instance, initialStatus), nil, nil)
839
1x

840
1x
    for {
841
1x
        select {
842
1x
        case <-ctx.Done():
843
1x
            w.logger.Info(ctx, "Worker shutting down", map[string]any{
844
1x
                "instance": w.instance,
845
1x
            })
846
1x
            w.logActivity(ctx, "INFO", fmt.Sprintf("Worker %s shutting down", w.instance), nil, nil)
847
1x
            w.status.IsRunning = false
848
1x
            w.updateDatabaseStatus(ctx)
849
1x
            return
850

851
        case <-ticker.C:
852
            w.run()
853

854
        case <-w.manualTrigger:
855
            w.logger.Info(ctx, "Worker triggered manually", map[string]any{
856
                "instance": w.instance,
857
            })
858
            w.logActivity(ctx, "INFO", fmt.Sprintf("Worker %s triggered manually", w.instance), nil, nil)
859
            w.run()
860
        }
861
    }
862
}
863

864
// handleStartupPause sets global pause if configured
865
3x
func (w *Worker) handleStartupPause(ctx context.Context) {
866
3x
    if w.workerCfg.StartWorkerPaused {
867
1x
        w.logger.Info(ctx, "Worker configured to start paused - setting global pause", map[string]interface{}{
868
1x
            "instance": w.instance,
869
1x
        })
870
1x
        if err := w.workerService.SetGlobalPause(ctx, true); err != nil {
871
            w.logger.Error(ctx, "Failed to set global pause on startup", err, map[string]interface{}{
872
                "instance": w.instance,
873
            })
874
        } else {
875
1x
            w.logger.Info(ctx, "Global pause set on startup as configured", map[string]interface{}{
876
1x
                "instance": w.instance,
877
1x
            })
878
1x
        }
879
    }
880
}
881

882
// getInitialWorkerStatus determines the initial status string
883
3x
func (w *Worker) getInitialWorkerStatus(ctx context.Context) string {
884
3x
    initialStatus := "running"
885
3x
    globalPaused, err := w.workerService.IsGlobalPaused(ctx)
886
3x
    if err != nil {
887
        w.logger.Error(ctx, "Failed to check global pause status on startup", err, map[string]interface{}{
888
            "instance": w.instance,
889
        })
890
    } else if globalPaused {
891
        initialStatus = "paused (globally)"
892
    } else {
893
3x
        status, err := w.workerService.GetWorkerStatus(ctx, w.instance)
894
3x
        if err != nil {
895
            // Worker status not found is expected on first startup - this is normal
896
            w.logger.Debug(ctx, "Worker status not found on startup (expected for new worker)", map[string]interface{}{
897
                "instance": w.instance,
898
            })
899
        } else if status != nil && status.IsPaused {
900
            initialStatus = "paused (instance)"
901
1x
        }
902
    }
903
3x
    return initialStatus
904
}
905

906
2x
func (w *Worker) heartbeatLoop(ctx context.Context) {
907
2x
    ticker := time.NewTicker(config.WorkerHeartbeatInterval)
908
2x
    defer ticker.Stop()
909
2x

910
2x
    for {
911
2x
        select {
912
2x
        case <-ctx.Done():
913
2x
            return
914
        case <-ticker.C:
915
            w.updateHeartbeat(ctx)
916
        }
917
    }
918
}
919

920
// updateHeartbeat updates the heartbeat in the database
921
1x
func (w *Worker) updateHeartbeat(ctx context.Context) {
922
1x
    if err := w.workerService.UpdateHeartbeat(ctx, w.instance); err != nil {
923
        w.logger.Error(ctx, "Failed to update heartbeat for worker", err, map[string]any{
924
            "instance": w.instance,
925
        })
926
    }
927
}
928

929
// run executes a single worker cycle
930
4x
func (w *Worker) run() {
931
4x
    ctx, span := observability.TraceWorkerFunction(context.Background(), "run",
932
4x
        attribute.String("worker.instance", w.instance),
933
4x
    )
934
4x
    defer observability.FinishSpan(span, nil)
935
4x

936
4x
    // Ensure worker status is up to date before checking pause status
937
4x
    w.updateDatabaseStatus(ctx)
938
4x

939
4x
    paused, reason := w.checkPauseStatus(ctx)
940
4x
    if paused {
941
1x
        span.SetAttributes(attribute.String("pause_reason", reason))
942
1x
        w.updateActivity(reason)
943
1x
        return
944
1x
    }
945

946
3x
    w.status.LastRunStart = time.Now()
947
3x
    w.updateDatabaseStatus(ctx)
948
3x
    details, err := w.generateNeededQuestions(ctx)
949
3x

950
3x
    // Assign daily questions to all eligible users (independent of email reminders)
951
3x
    if err := w.checkForDailyQuestionAssignments(ctx); err != nil {
952
        w.logger.Error(ctx, "Failed to check daily question assignments", err, map[string]interface{}{
953
            "instance": w.instance,
954
        })
955
    }
956

957
    // Generate story sections for users with active stories
958
3x
    if err := w.checkForStoryGenerations(ctx); err != nil {
959
        w.logger.Error(ctx, "Failed to check story generations", err, map[string]interface{}{
960
            "instance": w.instance,
961
        })
962
    }
963

964
    // Check for daily email reminders
965
3x
    if err := w.checkForDailyReminders(ctx); err != nil {
966
        w.logger.Error(ctx, "Failed to check daily reminders", err, map[string]interface{}{
967
            "instance": w.instance,
968
        })
969
    }
970

971
    // Check for word of the day assignments
972
3x
    if err := w.checkForWordOfTheDayAssignments(ctx); err != nil {
973
        w.logger.Error(ctx, "Failed to check word of the day assignments", err, map[string]interface{}{
974
            "instance": w.instance,
975
        })
976
    }
977

978
    // Check for word of the day emails
979
3x
    if err := w.checkForWordOfTheDayEmails(ctx); err != nil {
980
        w.logger.Error(ctx, "Failed to check word of the day emails", err, map[string]interface{}{
981
            "instance": w.instance,
982
        })
983
    }
984

985
    // Cleanup expired translation cache entries (once per day)
986
3x
    if err := w.cleanupTranslationCache(ctx); err != nil {
987
        w.logger.Error(ctx, "Failed to cleanup translation cache", err, map[string]interface{}{
988
            "instance": w.instance,
989
        })
990
    }
991

992
3x
    w.status.LastRunFinish = time.Now()
993
3x
    if err != nil {
994
        w.status.LastRunError = err.Error()
995
        w.logger.Error(ctx, "Worker run failed", err, map[string]interface{}{
996
            "instance": w.instance,
997
        })
998
    } else {
999
3x
        w.status.LastRunError = ""
1000
3x
    }
1001

1002
3x
    w.recordRunHistory(details, err)
1003
3x
    w.updateDatabaseStatus(ctx)
1004
}
1005

1006
// checkPauseStatus checks global and instance pause
1007
6x
func (w *Worker) checkPauseStatus(ctx context.Context) (bool, string) {
1008
6x
    globalPaused, err := w.workerService.IsGlobalPaused(ctx)
1009
6x
    if err != nil {
1010
        w.logger.Error(ctx, "Failed to check global pause status", err, map[string]interface{}{
1011
            "instance": w.instance,
1012
        })
1013
        return true, "Error checking global pause status"
1014
    }
1015
6x
    if globalPaused {
1016
3x
        return true, "Globally paused"
1017
3x
    }
1018
3x
    status, err := w.workerService.GetWorkerStatus(ctx, w.instance)
1019
3x
    if err != nil {
1020
        // Worker status not found might happen during startup - assume not paused
1021
        w.logger.Debug(ctx, "Worker status not found during pause check (assuming not paused)", map[string]interface{}{
1022
            "instance": w.instance,
1023
        })
1024
        return false, ""
1025
    } else if status != nil && status.IsPaused {
1026
        return true, "Worker instance paused"
1027
    }
1028
3x
    return false, ""
1029
}
1030

1031
// recordRunHistory records the run in history and trims the slice
1032
113x
func (w *Worker) recordRunHistory(details string, err error) {
1033
113x
    record := RunRecord{
1034
113x
        StartTime: w.status.LastRunStart,
1035
113x
        EndTime:   w.status.LastRunFinish,
1036
113x
        Duration:  w.status.LastRunFinish.Sub(w.status.LastRunStart),
1037
113x
        Details:   details,
1038
113x
    }
1039
113x
    if err != nil {
1040
        record.Status = "Failure"
1041
    } else {
1042
113x
        record.Status = "Success"
1043
113x
    }
1044
113x
    w.mu.Lock()
1045
113x
    w.history = append(w.history, record)
1046
113x
    if len(w.history) > w.cfg.Server.MaxHistory {
1047
5x
        w.history = w.history[len(w.history)-w.cfg.Server.MaxHistory:]
1048
5x
    }
1049
113x
    w.mu.Unlock()
1050
}
1051

1052
// GetStatus returns the current worker status
1053
5x
func (w *Worker) GetStatus() Status {
1054
5x
    w.mu.RLock()
1055
5x
    defer w.mu.RUnlock()
1056
5x
    return w.status
1057
5x
}
1058

1059
// GetHistory returns the worker's run history
1060
8x
func (w *Worker) GetHistory() []RunRecord {
1061
8x
    w.mu.RLock()
1062
8x
    defer w.mu.RUnlock()
1063
8x
    // Return a copy to avoid race conditions
1064
8x
    history := make([]RunRecord, len(w.history))
1065
8x
    copy(history, w.history)
1066
8x
    return history
1067
8x
}
1068

1069
// checkForStoryGenerations checks for users with active stories and generates new sections
1070
3x
func (w *Worker) checkForStoryGenerations(ctx context.Context) error {
1071
3x
    ctx, span := observability.TraceWorkerFunction(ctx, "check_story_generations",
1072
3x
        attribute.String("worker.instance", w.instance),
1073
3x
    )
1074
3x
    defer observability.FinishSpan(span, nil)
1075
3x

1076
3x
    w.updateActivity("Checking for story generations...")
1077
3x

1078
3x
    // Get all users with current active stories
1079
3x
    users, err := w.getUsersWithActiveStories(ctx)
1080
3x
    if err != nil {
1081
        return contextutils.WrapErrorf(err, "failed to get users with active stories")
1082
    }
1083

1084
3x
    w.logger.Info(ctx, "Found users with active stories",
1085
3x
        map[string]interface{}{
1086
3x
            "count":    len(users),
1087
3x
            "instance": w.instance,
1088
3x
        })
1089
3x

1090
3x
    processed := 0
1091
3x
    for _, user := range users {
1092
        if err := w.generateStorySection(ctx, user); err != nil {
1093
            // Check if this is a generation limit reached error (normal case for worker)
1094
            if errors.Is(err, contextutils.ErrGenerationLimitReached) {
1095
                w.logger.Info(ctx, "User reached daily generation limit, skipping",
1096
                    map[string]interface{}{
1097
                        "user_id":  user.ID,
1098
                        "username": user.Username,
1099
                        "instance": w.instance,
1100
                    })
1101
            } else {
1102
                w.logger.Error(ctx, "Failed to generate story section for user",
1103
                    err, map[string]interface{}{
1104
                        "user_id":  user.ID,
1105
                        "username": user.Username,
1106
                        "instance": w.instance,
1107
                    })
1108
            }
1109
            continue
1110
        }
1111
        processed++
1112
    }
1113

1114
3x
    w.updateActivity(fmt.Sprintf("Generated story sections for %d users", processed))
1115
3x
    w.logger.Info(ctx, "Story generation completed",
1116
3x
        map[string]interface{}{
1117
3x
            "processed": processed,
1118
3x
            "total":     len(users),
1119
3x
            "instance":  w.instance,
1120
3x
        })
1121
3x

1122
3x
    return nil
1123
}
1124

1125
// generateStorySection generates a new section for a user's current story
1126
func (w *Worker) generateStorySection(ctx context.Context, user models.User) error {
1127
    ctx, span := observability.TraceWorkerFunction(ctx, "generate_story_section",
1128
        attribute.String("worker.instance", w.instance),
1129
        attribute.String("user.username", user.Username),
1130
        attribute.Int("user.id", int(user.ID)),
1131
    )
1132
    defer observability.FinishSpan(span, nil)
1133

1134
    // Create a timeout context for story generation to prevent hanging requests
1135
    // Use the configured AI request timeout for consistency with other AI operations
1136
    timeoutCtx, cancel := context.WithTimeout(ctx, config.AIRequestTimeout)
1137
    defer cancel()
1138

1139
    // Get the user's current story
1140
    story, err := w.storyService.GetCurrentStory(timeoutCtx, uint(user.ID))
1141
    if err != nil {
1142
        return contextutils.WrapErrorf(err, "failed to get current story for user %d", user.ID)
1143
    }
1144
    if story == nil {
1145
        // No current story, skip
1146
        return nil
1147
    }
1148

1149
    // Get user's AI configuration
1150
    userConfig, apiKeyID := w.getUserAIConfig(timeoutCtx, &user)
1151

1152
    // Add user ID and API key ID to context for usage tracking
1153
    timeoutCtx = contextutils.WithUserID(timeoutCtx, user.ID)
1154
    if apiKeyID != nil {
1155
        timeoutCtx = contextutils.WithAPIKeyID(timeoutCtx, *apiKeyID)
1156
    }
1157

1158
    // Generate the story section using the shared service method (worker generation)
1159
    _, err = w.storyService.GenerateStorySection(timeoutCtx, story.ID, uint(user.ID), w.aiService, userConfig, models.GeneratorTypeWorker)
1160
    if err != nil {
1161
        // Check if this is a generation limit reached error (normal case for worker)
1162
        if errors.Is(err, contextutils.ErrGenerationLimitReached) {
1163
            w.logger.Info(ctx, "User reached daily generation limit, skipping",
1164
                map[string]interface{}{
1165
                    "user_id":  user.ID,
1166
                    "story_id": story.ID,
1167
                })
1168
            return nil // Skip this user, not an error
1169
        }
1170
        return contextutils.WrapErrorf(err, "failed to generate story section")
1171
    }
1172

1173
    return nil
1174
}
1175

1176
// getUsersWithActiveStories retrieves all users who have current active stories
1177
7x
func (w *Worker) getUsersWithActiveStories(ctx context.Context) ([]models.User, error) {
1178
7x
    // Get all users first
1179
7x
    allUsers, err := w.userService.GetAllUsers(ctx)
1180
7x
    if err != nil {
1181
        return nil, contextutils.WrapErrorf(err, "failed to get all users")
1182
    }
1183

1184
    // Filter to only users with current active stories and AI enabled
1185
7x
    var filteredUsers []models.User
1186
7x
    for _, user := range allUsers {
1187
5x
        // Check if user has AI enabled
1188
5x
        if !user.AIEnabled.Valid || !user.AIEnabled.Bool {
1189
1x
            continue
1190
        }
1191

1192
        // Check if user has valid AI provider and model
1193
4x
        if !user.AIProvider.Valid || !user.AIModel.Valid {
1194
            continue
1195
        }
1196

1197
        // Check if user has a current active story
1198
4x
        story, err := w.storyService.GetCurrentStory(ctx, uint(user.ID))
1199
4x
        if err != nil || story == nil {
1200
            continue
1201
        }
1202

1203
        // Check if story is active
1204
4x
        if story.Status != models.StoryStatusActive {
1205
            continue
1206
        }
1207

1208
        // Check if auto-generation is paused for this story
1209
4x
        if story.AutoGenerationPaused {
1210
            w.logger.Debug(ctx, "Skipping story with auto-generation paused",
1211
                map[string]interface{}{
1212
                    "user_id":  user.ID,
1213
                    "story_id": story.ID,
1214
                })
1215
            continue
1216
        }
1217

1218
4x
        filteredUsers = append(filteredUsers, user)
1219
    }
1220

1221
7x
    return filteredUsers, nil
1222
}
1223

1224
// GetActivityLogs returns recent activity logs
1225
7x
func (w *Worker) GetActivityLogs() []ActivityLog {
1226
7x
    w.mu.RLock()
1227
7x
    defer w.mu.RUnlock()
1228
7x

1229
7x
    // Return a copy to avoid concurrent access issues
1230
7x
    logs := make([]ActivityLog, len(w.activityLogs))
1231
7x
    copy(logs, w.activityLogs)
1232
7x
    return logs
1233
7x
}
1234

1235
// GetInstance returns the worker instance name
1236
1x
func (w *Worker) GetInstance() string {
1237
1x
    return w.instance
1238
1x
}
1239

1240
// GetEmailService returns the email service
1241
func (w *Worker) GetEmailService() mailer.Mailer {
1242
    return w.emailService
1243
}
1244

1245
// TriggerManualRun triggers a manual worker run
1246
5x
func (w *Worker) TriggerManualRun() {
1247
5x
    ctx := context.Background()
1248
5x
    select {
1249
3x
    case w.manualTrigger <- true:
1250
3x
        w.logger.Info(ctx, "Manual trigger sent to worker", map[string]interface{}{
1251
3x
            "instance": w.instance,
1252
3x
        })
1253
1x
    default:
1254
1x
        w.logger.Info(ctx, "Manual trigger already pending for worker", map[string]interface{}{
1255
1x
            "instance": w.instance,
1256
1x
        })
1257
    }
1258
}
1259

1260
// Pause pauses the worker
1261
2x
func (w *Worker) Pause(ctx context.Context) {
1262
2x
    if err := w.workerService.PauseWorker(ctx, w.instance); err != nil {
1263
1x
        w.logger.Warn(ctx, "Failed to pause worker in service", map[string]interface{}{
1264
1x
            "instance": w.instance,
1265
1x
            "error":    err.Error(),
1266
1x
        })
1267
1x
    }
1268
2x
    w.logger.Info(ctx, "Worker paused", map[string]interface{}{
1269
2x
        "instance": w.instance,
1270
2x
    })
1271
2x
    w.logActivity(ctx, "INFO", fmt.Sprintf("Worker %s paused", w.instance), nil, nil)
1272
2x
    w.status.IsPaused = true
1273
2x
    w.updateDatabaseStatus(ctx)
1274
}
1275

1276
// Resume resumes the worker
1277
2x
func (w *Worker) Resume(ctx context.Context) {
1278
2x
    if err := w.workerService.ResumeWorker(ctx, w.instance); err != nil {
1279
1x
        w.logger.Warn(ctx, "Failed to resume worker in service", map[string]interface{}{
1280
1x
            "instance": w.instance,
1281
1x
            "error":    err.Error(),
1282
1x
        })
1283
1x
        // Do not unpause if resume failed
1284
1x
        w.updateDatabaseStatus(ctx)
1285
1x
        return
1286
1x
    }
1287
1x
    w.logger.Info(ctx, "Worker resumed", map[string]interface{}{
1288
1x
        "instance": w.instance,
1289
1x
    })
1290
1x
    w.logActivity(ctx, "INFO", fmt.Sprintf("Worker %s resumed", w.instance), nil, nil)
1291
1x
    w.status.IsPaused = false
1292
1x
    w.updateDatabaseStatus(ctx)
1293
}
1294

1295
// Shutdown gracefully shuts down the worker and cleans up resources
1296
1x
func (w *Worker) Shutdown(ctx context.Context) error {
1297
1x
    w.mu.Lock()
1298
1x
    defer w.mu.Unlock()
1299
1x

1300
1x
    w.logger.Info(ctx, "Worker starting shutdown", map[string]interface{}{
1301
1x
        "instance": w.instance,
1302
1x
    })
1303
1x

1304
1x
    // Cancel the shutdown context to signal shutdown
1305
1x
    if w.cancel != nil {
1306
1x
        w.cancel()
1307
1x
    }
1308

1309
    // Wait for any active operations to complete
1310
    // This is a simple implementation - in a more complex system,
1311
    // you might want to track active operations more precisely
1312
1x
    time.Sleep(config.WorkerSleepDuration)
1313
1x

1314
1x
    // Clean up user failures map
1315
1x
    w.failureMu.Lock()
1316
1x
    w.userFailures = make(map[int]*UserFailureInfo)
1317
1x
    w.failureMu.Unlock()
1318
1x

1319
1x
    // Clear activity logs
1320
1x
    w.activityLogs = make([]ActivityLog, 0)
1321
1x

1322
1x
    w.logger.Info(ctx, "Worker shutdown completed", map[string]interface{}{
1323
1x
        "instance": w.instance,
1324
1x
    })
1325
1x
    return nil
1326
}
1327

1328
// updateDatabaseStatus updates the worker status in the database
1329
20x
func (w *Worker) updateDatabaseStatus(ctx context.Context) {
1330
20x
    dbStatus := &models.WorkerStatus{
1331
20x
        WorkerInstance:          w.instance,
1332
20x
        IsRunning:               w.status.IsRunning,
1333
20x
        IsPaused:                w.status.IsPaused,
1334
20x
        CurrentActivity:         sql.NullString{String: w.status.CurrentActivity, Valid: w.status.CurrentActivity != ""},
1335
20x
        LastHeartbeat:           sql.NullTime{Time: time.Now(), Valid: true},
1336
20x
        LastRunStart:            sql.NullTime{Time: w.status.LastRunStart, Valid: !w.status.LastRunStart.IsZero()},
1337
20x
        LastRunFinish:           sql.NullTime{Time: w.status.LastRunFinish, Valid: !w.status.LastRunFinish.IsZero()},
1338
20x
        LastRunError:            sql.NullString{String: w.status.LastRunError, Valid: w.status.LastRunError != ""},
1339
20x
        TotalQuestionsGenerated: w.getTotalQuestionsGenerated(),
1340
20x
        TotalRuns:               len(w.history),
1341
20x
    }
1342
20x

1343
20x
    if err := w.workerService.UpdateWorkerStatus(ctx, w.instance, dbStatus); err != nil {
1344
1x
        w.logger.Error(ctx, "Failed to update worker status in database", err, map[string]interface{}{
1345
1x
            "instance": w.instance,
1346
1x
        })
1347
1x
    }
1348
}
1349

1350
// getTotalQuestionsGenerated calculates total questions generated from run history
1351
20x
func (w *Worker) getTotalQuestionsGenerated() int {
1352
20x
    total := 0
1353
20x
    for _, record := range w.history {
1354
3x
        if record.Status == "Success" {
1355
3x
            // Parse details to count questions - simplified for now
1356
3x
            total++ // This would need to be enhanced to parse actual count
1357
3x
        }
1358
    }
1359
20x
    return total
1360
}
1361

1362
3x
func (w *Worker) generateNeededQuestions(ctx context.Context) (result0 string, err error) {
1363
3x
    ctx, span := observability.TraceWorkerFunction(ctx, "generate_needed_questions",
1364
3x
        attribute.String("worker.instance", w.instance),
1365
3x
    )
1366
3x
    defer observability.FinishSpan(span, &err)
1367
3x

1368
3x
    // Check if globally paused BEFORE any work or logging
1369
3x
    globalPaused, err := w.workerService.IsGlobalPaused(ctx)
1370
3x
    if err != nil {
1371
        span.RecordError(err)
1372
        w.logger.Error(ctx, "Failed to check global pause status", err, map[string]interface{}{
1373
            "instance": w.instance,
1374
        })
1375
        return "Error checking global pause status", err
1376
    }
1377
3x
    if globalPaused {
1378
        span.SetAttributes(attribute.Bool("globally_paused", true))
1379
        w.logger.Info(ctx, "Worker skipping question generation (globally paused)", map[string]interface{}{
1380
            "instance": w.instance,
1381
        })
1382
        return "Run paused globally", nil
1383
    }
1384

1385
3x
    aiUsers, err := w.getEligibleAIUsers(ctx)
1386
3x
    if err != nil {
1387
        return "Error getting users", err
1388
    }
1389
3x
    if len(aiUsers) == 0 {
1390
3x
        w.logger.Info(ctx, "Worker: No active users with AI provider configuration found for question generation", map[string]interface{}{
1391
3x
            "instance": w.instance,
1392
3x
        })
1393
3x
        return "No active users with AI provider configuration found", nil
1394
3x
    }
1395

1396
    var actions []string
1397
    var checkedUsers []string
1398
    var actuallyProcessedUsers []string
1399
    var hadAttemptedOperations bool
1400
    var hadFailures bool
1401

1402
    for _, user := range aiUsers {
1403
        checkedUsers = append(checkedUsers, user.Username)
1404
        shouldProcess, skipReason := w.shouldProcessUser(ctx, &user)
1405
        if !shouldProcess {
1406
            if skipReason != "" {
1407
                w.logger.Info(ctx, "Worker user check", map[string]interface{}{
1408
                    "instance": w.instance,
1409
                    "username": user.Username,
1410
                    "reason":   skipReason,
1411
                })
1412
            }
1413
            continue
1414
        }
1415
        actuallyProcessedUsers = append(actuallyProcessedUsers, user.Username)
1416
        userActions, attempted, failed := w.processUserQuestionGeneration(ctx, &user)
1417
        if attempted {
1418
            hadAttemptedOperations = true
1419
        }
1420
        if failed {
1421
            hadFailures = true
1422
        }
1423
        if userActions != "" {
1424
            actions = append(actions, userActions)
1425
        }
1426
        w.logger.Info(ctx, "Worker completed check for user", map[string]interface{}{
1427
            "instance": w.instance,
1428
            "username": user.Username,
1429
        })
1430
    }
1431

1432
    w.updateActivity("")
1433
    return w.summarizeRunActions(actions, checkedUsers, actuallyProcessedUsers, hadAttemptedOperations, hadFailures), nil
1434
}
1435

1436
// getEligibleAIUsers returns users eligible for AI question generation
1437
5x
func (w *Worker) getEligibleAIUsers(ctx context.Context) (result0 []models.User, err error) {
1438
5x
    ctx, span := observability.TraceWorkerFunction(ctx, "get_eligible_ai_users",
1439
5x
        attribute.String("worker.instance", w.instance),
1440
5x
    )
1441
5x
    defer observability.FinishSpan(span, &err)
1442
5x

1443
5x
    users, err := w.userService.GetAllUsers(ctx)
1444
5x
    if err != nil {
1445
        span.RecordError(err)
1446
        return nil, err
1447
    }
1448
5x
    var aiUsers []models.User
1449
5x
    for _, user := range users {
1450
7x
        if !user.AIEnabled.Valid || !user.AIEnabled.Bool {
1451
3x
            continue
1452
        }
1453
2x
        userPaused, err := w.workerService.IsUserPaused(ctx, user.ID)
1454
2x
        if err == nil && userPaused {
1455
1x
            continue
1456
        }
1457
1x
        hasAIProvider := user.AIProvider.Valid && user.AIProvider.String != ""
1458
1x
        hasAPIKey := false
1459
1x
        if hasAIProvider {
1460
1x
            savedKey, err := w.userService.GetUserAPIKey(ctx, user.ID, user.AIProvider.String)
1461
1x
            if err == nil && savedKey != "" {
1462
1x
                hasAPIKey = true
1463
1x
            }
1464
        }
1465
1x
        if hasAPIKey || hasAIProvider {
1466
1x
            aiUsers = append(aiUsers, user)
1467
1x
        }
1468
    }
1469
5x
    return aiUsers, nil
1470
}
1471

1472
// shouldProcessUser encapsulates exponential backoff and pause checks
1473
4x
func (w *Worker) shouldProcessUser(ctx context.Context, user *models.User) (bool, string) {
1474
4x
    if !w.shouldRetryUser(user.ID) {
1475
1x
        w.failureMu.RLock()
1476
1x
        failure := w.userFailures[user.ID]
1477
1x
        nextRetry := time.Until(failure.NextRetryTime)
1478
1x
        w.failureMu.RUnlock()
1479
1x
        return false, fmt.Sprintf("Skipping due to exponential backoff (failure #%d, retry in %v)", failure.ConsecutiveFailures, nextRetry.Round(time.Second))
1480
1x
    }
1481
3x
    globalPaused, err := w.workerService.IsGlobalPaused(ctx)
1482
3x
    if err != nil {
1483
        return false, "Error checking global pause status"
1484
    }
1485
3x
    if globalPaused {
1486
1x
        return false, "Run paused globally"
1487
1x
    }
1488
2x
    status, err := w.workerService.GetWorkerStatus(ctx, w.instance)
1489
2x
    if err == nil && status != nil && status.IsPaused {
1490
1x
        return false, fmt.Sprintf("Worker instance %s paused", w.instance)
1491
1x
    }
1492
1x
    if ctx.Err() != nil {
1493
1x
        return false, "Shutdown initiated"
1494
1x
    }
1495
    return true, ""
1496
}
1497

1498
// Helper: get the count of eligible questions for a user (excludes questions answered correctly in the last 2 days)
1499
14x
func (w *Worker) getEligibleQuestionCount(ctx context.Context, userID int, language, level string, qType models.QuestionType) (result0 int, err error) {
1500
14x
    ctx, span := observability.TraceWorkerFunction(ctx, "get_eligible_question_count",
1501
14x
        observability.AttributeUserID(userID),
1502
14x
        attribute.String("language", language),
1503
14x
        attribute.String("level", level),
1504
14x
        attribute.String("question.type", string(qType)),
1505
14x
        attribute.String("worker.instance", w.instance),
1506
14x
    )
1507
14x
    defer observability.FinishSpan(span, &err)
1508
14x

1509
14x
    // Safe user lookup: tests may not wire userService
1510
14x
    userLookup := func(ctx context.Context, id int) (*models.User, error) {
1511
14x
        // Only use the concrete UserService implementation to avoid invoking mocks in unit tests
1512
14x
        if us, ok := w.userService.(*services.UserService); ok && us != nil {
1513
2x
            return us.GetUserByID(ctx, id)
1514
2x
        }
1515
        // No userService available or not concrete - return nil so helper falls back to UTC
1516
6x
        return nil, nil
1517
    }
1518

1519
    // Determine user-local 2-day window and pass UTC timestamps to query
1520
14x
    startUTC, endUTC, _, err := contextutils.UserLocalDayRange(ctx, userID, 2, userLookup)
1521
14x
    if err != nil {
1522
        return 0, contextutils.WrapError(err, "failed to compute user local day range")
1523
    }
1524

1525
14x
    query := `
1526
14x
        SELECT COUNT(*)
1527
14x
        FROM questions q
1528
14x
        JOIN user_questions uq ON q.id = uq.question_id
1529
14x
        WHERE uq.user_id = $1
1530
14x
          AND q.language = $2
1531
14x
          AND q.level = $3
1532
14x
          AND q.type = $4
1533
14x
          AND q.status = 'active'
1534
14x
          AND NOT EXISTS (
1535
14x
                SELECT 1 FROM user_responses ur
1536
14x
                WHERE ur.user_id = $1
1537
14x
                  AND ur.question_id = q.id
1538
14x
                  AND ur.is_correct = TRUE
1539
14x
                  AND ur.created_at >= $5 AND ur.created_at < $6
1540
14x
          )
1541
14x
    `
1542
14x

1543
14x
    // Try to get the database from the question service
1544
14x
    var db *sql.DB
1545
14x
    if qs, ok := w.questionService.(*services.QuestionService); ok {
1546
2x
        db = qs.DB()
1547
2x
    } else {
1548
6x
        // For mock services or other implementations, we can't get the DB directly
1549
6x
        // This is expected in unit tests
1550
6x
        return 0, contextutils.ErrorWithContextf("cannot get database from question service implementation")
1551
6x
    }
1552

1553
2x
    row := db.QueryRowContext(ctx, query, userID, language, level, qType, startUTC, endUTC)
1554
2x
    var count int
1555
2x
    if err := row.Scan(&count); err != nil {
1556
        return 0, err
1557
    }
1558
2x
    return count, nil
1559
}
1560

1561
1x
func (w *Worker) processUserQuestionGeneration(ctx context.Context, user *models.User) (string, bool, bool) {
1562
1x
    ctx, span := observability.TraceWorkerFunction(ctx, "processUserQuestionGeneration",
1563
1x
        observability.AttributeUserID(user.ID),
1564
1x
        attribute.String("user.username", user.Username),
1565
1x
        attribute.String("worker.instance", w.instance),
1566
1x
    )
1567
1x
    defer observability.FinishSpan(span, nil)
1568
1x

1569
1x
    userLanguage := "italian"
1570
1x
    if user.PreferredLanguage.Valid && user.PreferredLanguage.String != "" {
1571
1x
        userLanguage = user.PreferredLanguage.String
1572
1x
        span.SetAttributes(attribute.String("user.language", userLanguage))
1573
1x
    }
1574
1x
    userLevel := "A1"
1575
1x
    if user.CurrentLevel.Valid && user.CurrentLevel.String != "" {
1576
1x
        userLevel = user.CurrentLevel.String
1577
1x
        span.SetAttributes(attribute.String("user.level", userLevel))
1578
1x
    }
1579
1x
    languages := []string{userLanguage}
1580
1x
    levels := []string{userLevel}
1581
1x
    questionTypes := []models.QuestionType{
1582
1x
        models.Vocabulary,
1583
1x
        models.FillInBlank,
1584
1x
        models.QuestionAnswer,
1585
1x
        models.ReadingComprehension,
1586
1x
    }
1587
1x

1588
1x
    // Reorder types based on active generation hints (hinted types first, stable order)
1589
1x
    if w.hintService != nil {
1590
        if hints, err := w.hintService.GetActiveHintsForUser(ctx, user.ID); err == nil && len(hints) > 0 {
1591
            hinted := make([]models.QuestionType, 0, len(hints))
1592
            hintedSet := map[models.QuestionType]bool{}
1593
            for _, h := range hints {
1594
                qt := models.QuestionType(h.QuestionType)
1595
                hinted = append(hinted, qt)
1596
                hintedSet[qt] = true
1597
            }
1598
            rest := make([]models.QuestionType, 0, len(questionTypes))
1599
            for _, qt := range questionTypes {
1600
                if !hintedSet[qt] {
1601
                    rest = append(rest, qt)
1602
                }
1603
            }
1604
            questionTypes = append(hinted, rest...)
1605
        }
1606
    }
1607
1x
    var actions []string
1608
1x
    var hadAttemptedOperations bool
1609
1x
    var hadFailures bool
1610
1x
    for _, language := range languages {
1611
1x
        for _, level := range levels {
1612
1x
            for _, qType := range questionTypes {
1613
4x
                activity := fmt.Sprintf("Checking questions for user %s: %s %s %s", user.Username, language, level, qType)
1614
4x
                w.updateActivity(activity)
1615
4x
                // Use eligible question count (not just total assigned)
1616
4x
                eligibleCount, err := w.getEligibleQuestionCount(ctx, user.ID, language, level, qType)
1617
4x
                if err != nil {
1618
4x
                    span.RecordError(err)
1619
4x
                    hadFailures = true
1620
4x
                    continue // Continue to next question type
1621
                }
1622
                // If hinted, be more aggressive about generating for that type
1623
                hinted := false
1624
                if w.hintService != nil {
1625
                    if hints, err := w.hintService.GetActiveHintsForUser(ctx, user.ID); err == nil {
1626
                        for _, h := range hints {
1627
                            if models.QuestionType(h.QuestionType) == qType {
1628
                                hinted = true
1629
                                break
1630
                            }
1631
                        }
1632
                    }
1633
                }
1634

1635
                refillThreshold := w.cfg.Server.QuestionRefillThreshold
1636
                if hinted {
1637
                    // Treat as if pool is empty to trigger generation, but keep batch sizing logic
1638
                    eligibleCount = 0
1639
                }
1640

1641
                if eligibleCount < refillThreshold {
1642
                    provider := "default"
1643
                    if user.AIProvider.Valid && user.AIProvider.String != "" {
1644
                        provider = user.AIProvider.String
1645
                    }
1646
                    // Base batch size from AI provider
1647
                    needed := w.aiService.GetQuestionBatchSize(provider)
1648

1649
                    // Get user's learning preferences to use their personal FreshQuestionRatio
1650
                    userPrefs, prefsErr := w.learningService.GetUserLearningPreferences(ctx, user.ID)
1651
                    userFreshRatio := 0.7 // default fallback
1652
                    if prefsErr == nil && userPrefs != nil && userPrefs.FreshQuestionRatio > 0 {
1653
                        userFreshRatio = userPrefs.FreshQuestionRatio
1654
                    } else if prefsErr != nil {
1655
                        w.logger.Warn(ctx, "Failed to get user learning preferences, using default fresh ratio", map[string]interface{}{
1656
                            "user_id": user.ID,
1657
                            "error":   prefsErr.Error(),
1658
                        })
1659
                    }
1660

1661
                    // Ensure at least enough fresh questions are available to meet the user's personal FreshQuestionRatio.
1662
                    // This ensures daily question assignment can respect the user's freshness preference.
1663
                    desiredFresh := int(math.Ceil(float64(refillThreshold) * userFreshRatio))
1664
                    freshCandidates := 0
1665
                    if qs, qerr := w.questionService.GetAdaptiveQuestionsForDaily(ctx, user.ID, language, level, 50); qerr == nil && qs != nil {
1666
                        for _, q := range qs {
1667
                            if q != nil && q.TotalResponses == 0 {
1668
                                freshCandidates++
1669
                            }
1670
                        }
1671
                    } else if qerr != nil {
1672
                        // Log but don't fail - we'll conservatively proceed with base batch size
1673
                        w.logger.Warn(ctx, "Failed to fetch adaptive questions for fresh-count check", map[string]interface{}{
1674
                            "user_id": user.ID,
1675
                            "error":   qerr.Error(),
1676
                        })
1677
                    }
1678

1679
                    if missing := desiredFresh - freshCandidates; missing > 0 {
1680
                        needed += missing
1681
                        w.logger.Info(ctx, "Adjusting generation batch to meet user's personal fresh-question requirement", map[string]interface{}{
1682
                            "user_id":          user.ID,
1683
                            "language":         language,
1684
                            "level":            level,
1685
                            "question_type":    qType,
1686
                            "user_fresh_ratio": userFreshRatio,
1687
                            "base_batch_size":  w.aiService.GetQuestionBatchSize(provider),
1688
                            "desired_fresh":    desiredFresh,
1689
                            "fresh_candidates": freshCandidates,
1690
                            "added_to_batch":   missing,
1691
                            "final_batch_size": needed,
1692
                        })
1693
                    }
1694
                    hadAttemptedOperations = true
1695
                    action, err := w.GenerateQuestionsForUser(ctx, user, language, level, qType, needed, "")
1696
                    if err != nil {
1697
                        hadFailures = true
1698
                        // Continue to next question type instead of breaking all loops
1699
                        continue
1700
                    }
1701
                    if action != "" {
1702
                        actions = append(actions, action)
1703
                    }
1704
                    // Clear hint on successful generation attempt for this type
1705
                    if hinted && w.hintService != nil {
1706
                        _ = w.hintService.ClearHint(ctx, user.ID, language, level, qType)
1707
                    }
1708
                }
1709
            }
1710
        }
1711
    }
1712
1x
    return strings.Join(actions, "; "), hadAttemptedOperations, hadFailures
1713
}
1714

1715
// summarizeRunActions builds the summary string for actions taken
1716
4x
func (w *Worker) summarizeRunActions(actions, checkedUsers, actuallyProcessedUsers []string, hadAttemptedOperations, hadFailures bool) string {
1717
4x
    userList := "No users with AI configuration found"
1718
4x
    if len(checkedUsers) > 0 {
1719
4x
        userList = fmt.Sprintf("Checked users: %s", strings.Join(checkedUsers, ", "))
1720
4x
    }
1721
4x
    if len(actions) == 0 {
1722
3x
        if len(actuallyProcessedUsers) == 0 {
1723
1x
            return fmt.Sprintf("No actions taken. All users in exponential backoff. %s", userList)
1724
1x
        }
1725
2x
        if hadAttemptedOperations && hadFailures && len(actions) == 0 {
1726
1x
            return fmt.Sprintf("No actions taken due to errors. %s", userList)
1727
1x
        }
1728
1x
        return fmt.Sprintf("No actions taken. All question types have sufficient questions. %s", userList)
1729
    }
1730
1x
    userList = fmt.Sprintf("Processed users: %s", strings.Join(actuallyProcessedUsers, ", "))
1731
1x

1732
1x
    // Format actions with line breaks for better readability in UI
1733
1x
    if len(actions) == 1 {
1734
1x
        return fmt.Sprintf("%s\n%s", actions[0], userList)
1735
1x
    }
1736

1737
    formattedActions := strings.Join(actions, "\n")
1738
    return fmt.Sprintf("%s\n%s", formattedActions, userList)
1739
}
1740

1741
// GenerateQuestionsForUser generates questions for a specific user with the given parameters
1742
1x
func (w *Worker) GenerateQuestionsForUser(ctx context.Context, user *models.User, language, level string, qType models.QuestionType, count int, topic string) (result0 string, err error) {
1743
1x
    ctx, span := observability.TraceWorkerFunction(ctx, "generate_questions_for_user",
1744
1x
        observability.AttributeUserID(user.ID),
1745
1x
        attribute.String("user.username", user.Username),
1746
1x
        attribute.String("language", language),
1747
1x
        attribute.String("level", level),
1748
1x
        attribute.String("question.type", string(qType)),
1749
1x
        attribute.Int("question.count", count),
1750
1x
        attribute.String("topic", topic),
1751
1x
        attribute.String("worker.instance", w.instance),
1752
1x
    )
1753
1x
    defer observability.FinishSpan(span, &err)
1754
1x

1755
1x
    if count <= 0 {
1756
        return "No questions needed", nil
1757
    }
1758

1759
    // Gather priority data for variety selection
1760
1x
    priorityData := w.getPriorityGenerationData(ctx, user.ID, language, level, qType)
1761
1x
    var userWeakAreas []string
1762
1x
    if priorityData != nil && priorityData.FocusOnWeakAreas {
1763
        userWeakAreas = priorityData.UserWeakAreas
1764
    }
1765
1x
    var highPriorityTopics []string
1766
1x
    if priorityData != nil {
1767
1x
        highPriorityTopics = priorityData.HighPriorityTopics
1768
1x
    }
1769
1x
    var gapAnalysis map[string]int
1770
1x
    if priorityData != nil {
1771
1x
        gapAnalysis = priorityData.GapAnalysis
1772
1x
    }
1773

1774
1x
    variety := w.aiService.VarietyService().SelectVarietyElements(ctx, level, highPriorityTopics, userWeakAreas, gapAnalysis)
1775
1x

1776
1x
    // Log priority generation decisions
1777
1x
    generationReasoning := w.getGenerationReasoning(priorityData, variety)
1778
1x

1779
1x
    var freshQuestionRatio float64
1780
1x
    if priorityData != nil {
1781
1x
        freshQuestionRatio = priorityData.FreshQuestionRatio
1782
1x
    }
1783

1784
1x
    priorityLog := PriorityGenerationLog{
1785
1x
        UserID:              user.ID,
1786
1x
        Username:            user.Username,
1787
1x
        Language:            language,
1788
1x
        Level:               level,
1789
1x
        QuestionType:        string(qType),
1790
1x
        FocusOnWeakAreas:    priorityData != nil && priorityData.FocusOnWeakAreas,
1791
1x
        UserWeakAreas:       userWeakAreas,
1792
1x
        HighPriorityTopics:  highPriorityTopics,
1793
1x
        GapAnalysis:         gapAnalysis,
1794
1x
        FreshQuestionRatio:  freshQuestionRatio,
1795
1x
        SelectedVariety:     variety,
1796
1x
        GenerationReasoning: generationReasoning,
1797
1x
        Timestamp:           time.Now(),
1798
1x
    }
1799
1x
    w.logPriorityGeneration(ctx, priorityLog)
1800
1x

1801
1x
    aiReq, recentQuestions, err := w.buildAIQuestionGenRequest(ctx, user, language, level, qType, count, topic)
1802
1x
    if err != nil {
1803
        w.logger.Warn(ctx, "Worker failed to get recent questions", map[string]interface{}{
1804
            "instance": w.instance,
1805
            "error":    err.Error(),
1806
        })
1807
        return "", contextutils.WrapError(err, "failed to build AI request")
1808
    }
1809
1x
    aiReq.RecentQuestionHistory = recentQuestions
1810
1x

1811
1x
    userConfig, apiKeyID := w.getUserAIConfig(ctx, user)
1812
1x

1813
1x
    batchLogMsg := formatBatchLogMessage(user.Username, count, string(qType), language, level, variety, userConfig.Provider, userConfig.Model)
1814
1x
    w.logger.Info(ctx, batchLogMsg, map[string]interface{}{
1815
1x
        "instance": w.instance,
1816
1x
    })
1817
1x
    w.updateActivity(batchLogMsg)
1818
1x
    w.logActivity(ctx, "INFO", batchLogMsg, &user.ID, &user.Username)
1819
1x

1820
1x
    progressMsg, questions, errAI := w.handleAIQuestionStream(ctx, userConfig, apiKeyID, aiReq, variety, count, language, level, qType, topic, user)
1821
1x

1822
1x
    if errAI != nil {
1823
        w.recordUserFailure(ctx, user.ID, user.Username)
1824
        return progressMsg, errAI
1825
    }
1826
1x
    if len(questions) == 0 {
1827
        w.recordUserFailure(ctx, user.ID, user.Username)
1828
        return progressMsg, contextutils.WrapErrorf(contextutils.ErrAIResponseInvalid, "AI service returned 0 questions for %s %s %s", language, level, qType)
1829
    }
1830

1831
1x
    savedCount := w.saveGeneratedQuestions(ctx, user, questions, language, level, qType, topic, variety)
1832
1x

1833
1x
    if savedCount > 0 {
1834
1x
        w.recordUserSuccess(ctx, user.ID, user.Username)
1835
1x
    }
1836
1x
    if savedCount != len(questions) {
1837
        w.recordUserFailure(ctx, user.ID, user.Username)
1838
        return fmt.Sprintf("Generated %d/%d %s questions for %s %s", savedCount, len(questions), qType, language, level),
1839
            contextutils.WrapErrorf(contextutils.ErrDatabaseQuery, "only saved %d out of %d generated questions", savedCount, len(questions))
1840
    }
1841
1x
    return fmt.Sprintf("Generated %d %s questions for %s %s", savedCount, qType, language, level), nil
1842
}
1843

1844
// buildAIQuestionGenRequest prepares the AI request and gets recent questions
1845
5x
func (w *Worker) buildAIQuestionGenRequest(ctx context.Context, user *models.User, language, level string, qType models.QuestionType, count int, _ string) (result0 *models.AIQuestionGenRequest, result1 []string, err error) {
1846
5x
    ctx, span := observability.TraceWorkerFunction(ctx, "build_ai_question_gen_request",
1847
5x
        observability.AttributeUserID(user.ID),
1848
5x
        attribute.String("user.username", user.Username),
1849
5x
        attribute.String("language", language),
1850
5x
        attribute.String("level", level),
1851
5x
        attribute.String("question.type", string(qType)),
1852
5x
        attribute.Int("question.count", count),
1853
5x
        attribute.String("worker.instance", w.instance),
1854
5x
    )
1855
5x
    defer observability.FinishSpan(span, &err)
1856
5x

1857
5x
    recentQuestions, err := w.questionService.GetRecentQuestionContentsForUser(ctx, user.ID, 10)
1858
5x
    if err != nil {
1859
        span.RecordError(err)
1860
        return nil, nil, err
1861
    }
1862
5x
    aiReq := &models.AIQuestionGenRequest{
1863
5x
        Language:     language,
1864
5x
        Level:        level,
1865
5x
        QuestionType: qType,
1866
5x
        Count:        count,
1867
5x
    }
1868
5x

1869
5x
    aiReq.RecentQuestionHistory = recentQuestions
1870
5x

1871
5x
    return aiReq, recentQuestions, nil
1872
}
1873

1874
// getUserAIConfig builds the UserAIConfig struct with API key and returns the API key ID
1875
9x
func (w *Worker) getUserAIConfig(ctx context.Context, user *models.User) (*models.UserAIConfig, *int) {
1876
9x
    ctx, span := observability.TraceWorkerFunction(ctx, "get_user_ai_config",
1877
9x
        observability.AttributeUserID(user.ID),
1878
9x
        attribute.String("user.username", user.Username),
1879
9x
        attribute.String("worker.instance", w.instance),
1880
9x
    )
1881
9x
    defer observability.FinishSpan(span, nil)
1882
9x

1883
9x
    provider := ""
1884
9x
    if user.AIProvider.Valid {
1885
7x
        provider = user.AIProvider.String
1886
7x
        span.SetAttributes(attribute.String("ai.provider", provider))
1887
7x
    }
1888
9x
    model := ""
1889
9x
    if user.AIModel.Valid {
1890
7x
        model = user.AIModel.String
1891
7x
        span.SetAttributes(attribute.String("ai.model", model))
1892
7x
    }
1893
9x
    apiKey := ""
1894
9x
    var apiKeyID *int
1895
9x
    if provider != "" {
1896
7x
        savedKey, keyID, err := w.userService.GetUserAPIKeyWithID(ctx, user.ID, provider)
1897
7x
        if err == nil && savedKey != "" {
1898
3x
            apiKey = savedKey
1899
3x
            apiKeyID = keyID
1900
3x
        }
1901
    }
1902
9x
    return &models.UserAIConfig{
1903
9x
        Provider: provider,
1904
9x
        Model:    model,
1905
9x
        APIKey:   apiKey,
1906
9x
        Username: user.Username,
1907
9x
    }, apiKeyID
1908
}
1909

1910
// handleAIQuestionStream handles the AI streaming and collects questions
1911
5x
func (w *Worker) handleAIQuestionStream(ctx context.Context, userConfig *models.UserAIConfig, apiKeyID *int, req *models.AIQuestionGenRequest, variety *services.VarietyElements, count int, language, level string, qType models.QuestionType, topic string, user *models.User) (result0 string, result1 []*models.Question, err error) {
1912
5x
    ctx, span := observability.TraceWorkerFunction(ctx, "handle_ai_question_stream",
1913
5x
        attribute.String("ai.provider", userConfig.Provider),
1914
5x
        attribute.String("ai.model", userConfig.Model),
1915
5x
        attribute.String("language", language),
1916
5x
        attribute.String("level", level),
1917
5x
        attribute.String("question.type", string(qType)),
1918
5x
        attribute.Int("question.count", count),
1919
5x
        attribute.String("topic", topic),
1920
5x
        attribute.String("user.username", user.Username),
1921
5x
        attribute.String("worker.instance", w.instance),
1922
5x
    )
1923
5x
    defer observability.FinishSpan(span, &err)
1924
5x

1925
5x
    // Add user ID and API key ID to context for usage tracking
1926
5x
    ctx = contextutils.WithUserID(ctx, user.ID)
1927
5x
    if apiKeyID != nil {
1928
1x
        ctx = contextutils.WithAPIKeyID(ctx, *apiKeyID)
1929
1x
    }
1930

1931
5x
    progressChan := make(chan *models.Question)
1932
5x
    var questions []*models.Question
1933
5x
    var wg sync.WaitGroup
1934
5x
    var errAI error
1935
5x
    progressMsg := ""
1936
5x
    wg.Add(1)
1937
5x
    go func() {
1938
5x
        defer func() {
1939
5x
            if r := recover(); r != nil {
1940
                w.logger.Error(ctx, "Panic in AI question stream goroutine", nil, map[string]interface{}{
1941
                    "instance": w.instance,
1942
                    "panic":    fmt.Sprintf("%v", r),
1943
                })
1944
            }
1945
5x
            wg.Done()
1946
        }()
1947
5x
        errAI = w.aiService.GenerateQuestionsStream(ctx, userConfig, req, progressChan, variety)
1948
    }()
1949
5x
    generatedCount := 0
1950
5x
    for question := range progressChan {
1951
1x
        generatedCount++
1952
1x
        progressMsg = fmt.Sprintf("Generated %d/%d %s questions for %s %s", generatedCount, count, qType, language, level)
1953
1x
        if topic != "" {
1954
1x
            progressMsg = fmt.Sprintf("Generated %d/%d %s questions for %s %s (topic: %s)", generatedCount, count, qType, language, level, topic)
1955
1x
        }
1956
1x
        w.logger.Info(ctx, progressMsg, map[string]interface{}{
1957
1x
            "instance": w.instance,
1958
1x
        })
1959
1x
        w.updateActivity(progressMsg)
1960
1x
        w.logActivity(ctx, "INFO", progressMsg, &user.ID, &user.Username)
1961
1x
        questions = append(questions, question)
1962
    }
1963
5x
    wg.Wait()
1964
5x
    return progressMsg, questions, errAI
1965
}
1966

1967
// saveGeneratedQuestions saves questions to the DB and returns the count
1968
7x
func (w *Worker) saveGeneratedQuestions(ctx context.Context, user *models.User, questions []*models.Question, language, level string, qType models.QuestionType, topic string, variety *services.VarietyElements) int {
1969
7x
    ctx, span := observability.TraceWorkerFunction(ctx, "save_generated_questions",
1970
7x
        observability.AttributeUserID(user.ID),
1971
7x
        attribute.String("user.username", user.Username),
1972
7x
        attribute.String("language", language),
1973
7x
        attribute.String("level", level),
1974
7x
        attribute.String("question.type", string(qType)),
1975
7x
        attribute.Int("question.count", len(questions)),
1976
7x
        attribute.String("topic", topic),
1977
7x
        attribute.String("worker.instance", w.instance),
1978
7x
    )
1979
7x
    defer observability.FinishSpan(span, nil)
1980
7x

1981
7x
    savingMsg := fmt.Sprintf("Saving %d new %s questions for %s %s", len(questions), qType, language, level)
1982
7x
    if topic != "" {
1983
3x
        savingMsg = fmt.Sprintf("Saving %d new %s questions for %s %s (topic: %s)", len(questions), qType, language, level, topic)
1984
3x
    }
1985
7x
    w.logger.Info(ctx, savingMsg, map[string]interface{}{
1986
7x
        "instance": w.instance,
1987
7x
    })
1988
7x
    w.updateActivity(savingMsg)
1989
7x
    w.logActivity(ctx, "INFO", savingMsg, &user.ID, &user.Username)
1990
7x
    savedCount := 0
1991
7x
    for _, q := range questions {
1992
9x
        // Populate variety fields from the variety elements used during generation
1993
9x
        if variety != nil {
1994
7x
            q.TopicCategory = variety.TopicCategory
1995
7x
            q.GrammarFocus = variety.GrammarFocus
1996
7x
            q.VocabularyDomain = variety.VocabularyDomain
1997
7x
            q.Scenario = variety.Scenario
1998
7x
            q.StyleModifier = variety.StyleModifier
1999
7x
            q.DifficultyModifier = variety.DifficultyModifier
2000
7x
            q.TimeContext = variety.TimeContext
2001
7x
        }
2002
9x
        if err := w.questionService.SaveQuestion(ctx, q); err != nil {
2003
            w.logger.Error(ctx, "Failed to save generated question", err, map[string]interface{}{
2004
                "instance":      w.instance,
2005
                "user_id":       user.ID,
2006
                "language":      language,
2007
                "level":         level,
2008
                "question_type": qType,
2009
            })
2010
        } else {
2011
9x
            // Assign the question to the user after saving
2012
9x
            if err := w.questionService.AssignQuestionToUser(ctx, q.ID, user.ID); err != nil {
2013
                w.logger.Error(ctx, "Failed to assign question to user", err, map[string]interface{}{
2014
                    "instance":    w.instance,
2015
                    "question_id": q.ID,
2016
                    "user_id":     user.ID,
2017
                })
2018
            } else {
2019
9x
                savedCount++
2020
9x
            }
2021
        }
2022
    }
2023
7x
    if savedCount > 0 {
2024
7x
        successMsg := fmt.Sprintf("Successfully saved %d new '%s' questions for %s %s", savedCount, qType, language, level)
2025
7x
        if topic != "" {
2026
3x
            successMsg = fmt.Sprintf("Successfully saved %d new '%s' questions for %s %s (topic: %s)", savedCount, qType, language, level, topic)
2027
3x
        }
2028
7x
        w.logActivity(ctx, "INFO", successMsg, &user.ID, &user.Username)
2029
    }
2030
7x
    return savedCount
2031
}
2032

2033
26x
func (w *Worker) updateActivity(activity string) {
2034
26x
    w.mu.Lock()
2035
26x
    defer w.mu.Unlock()
2036
26x
    w.status.CurrentActivity = activity
2037
26x
}
2038

2039
// logActivity adds an activity log entry
2040
247x
func (w *Worker) logActivity(_ context.Context, _, message string, userID *int, username *string) {
2041
247x
    w.mu.Lock()
2042
247x
    defer w.mu.Unlock()
2043
247x

2044
247x
    logEntry := ActivityLog{
2045
247x
        Timestamp: time.Now(),
2046
247x
        Level:     "INFO",
2047
247x
        Message:   message,
2048
247x
        UserID:    userID,
2049
247x
        Username:  username,
2050
247x
    }
2051
247x

2052
247x
    // Add to activity logs (circular buffer)
2053
247x
    w.activityLogs = append(w.activityLogs, logEntry)
2054
247x

2055
247x
    // Keep only the last maxActivityLogs entries
2056
247x
    if len(w.activityLogs) > w.cfg.Server.MaxActivityLogs {
2057
10x
        w.activityLogs = w.activityLogs[len(w.activityLogs)-w.cfg.Server.MaxActivityLogs:]
2058
10x
    }
2059
}
2060

2061
// shouldRetryUser checks if enough time has passed since the last failure for exponential backoff
2062
7x
func (w *Worker) shouldRetryUser(userID int) bool {
2063
7x
    w.failureMu.RLock()
2064
7x
    defer w.failureMu.RUnlock()
2065
7x

2066
7x
    failure, exists := w.userFailures[userID]
2067
7x
    if !exists {
2068
4x
        return true // No previous failures, go ahead
2069
4x
    }
2070

2071
3x
    return time.Now().After(failure.NextRetryTime)
2072
}
2073

2074
// recordUserFailure records a failure and calculates the next retry time with exponential backoff
2075
11x
func (w *Worker) recordUserFailure(ctx context.Context, userID int, username string) {
2076
11x
    ctx, span := observability.TraceWorkerFunction(ctx, "record_user_failure",
2077
11x
        observability.AttributeUserID(userID),
2078
11x
        attribute.String("user.username", username),
2079
11x
        attribute.String("worker.instance", w.instance),
2080
11x
    )
2081
11x
    defer observability.FinishSpan(span, nil)
2082
11x

2083
11x
    w.failureMu.Lock()
2084
11x
    defer w.failureMu.Unlock()
2085
11x

2086
11x
    failure, exists := w.userFailures[userID]
2087
11x
    if !exists {
2088
7x
        failure = &UserFailureInfo{}
2089
7x
        w.userFailures[userID] = failure
2090
7x
    }
2091

2092
11x
    failure.ConsecutiveFailures++
2093
11x
    failure.LastFailureTime = time.Now()
2094
11x

2095
11x
    // Exponential backoff: 2^failures seconds, max 1 hour
2096
11x
    backoffSeconds := int(math.Pow(2, float64(failure.ConsecutiveFailures)))
2097
11x
    if backoffSeconds > 3600 {
2098
        backoffSeconds = 3600
2099
    }
2100
11x
    failure.NextRetryTime = time.Now().Add(time.Duration(backoffSeconds) * time.Second)
2101
11x

2102
11x
    span.SetAttributes(
2103
11x
        attribute.Int("failure.count", failure.ConsecutiveFailures),
2104
11x
        attribute.Int("backoff.seconds", backoffSeconds),
2105
11x
    )
2106
11x

2107
11x
    w.logger.Info(ctx, "Worker recorded user failure", map[string]interface{}{
2108
11x
        "instance":           w.instance,
2109
11x
        "username":           username,
2110
11x
        "failure_count":      failure.ConsecutiveFailures,
2111
11x
        "next_retry_seconds": backoffSeconds,
2112
11x
    })
2113
}
2114

2115
// recordUserSuccess clears the failure count for a user
2116
5x
func (w *Worker) recordUserSuccess(ctx context.Context, userID int, username string) {
2117
5x
    ctx, span := observability.TraceWorkerFunction(ctx, "record_user_success",
2118
5x
        observability.AttributeUserID(userID),
2119
5x
        attribute.String("user.username", username),
2120
5x
        attribute.String("worker.instance", w.instance),
2121
5x
    )
2122
5x
    defer observability.FinishSpan(span, nil)
2123
5x

2124
5x
    w.failureMu.Lock()
2125
5x
    defer w.failureMu.Unlock()
2126
5x

2127
5x
    failure, exists := w.userFailures[userID]
2128
5x
    if exists && failure.ConsecutiveFailures > 0 {
2129
1x
        span.SetAttributes(attribute.Int("previous_failures", failure.ConsecutiveFailures))
2130
1x
        w.logger.Info(ctx, "Worker user success after failures, resetting backoff", map[string]interface{}{
2131
1x
            "instance":          w.instance,
2132
1x
            "username":          username,
2133
1x
            "previous_failures": failure.ConsecutiveFailures,
2134
1x
        })
2135
1x
        delete(w.userFailures, userID)
2136
1x
    }
2137
}
2138

2139
// formatBatchLogMessage creates a formatted log message for batch question generation
2140
7x
func formatBatchLogMessage(username string, count int, qType, language, level string, variety *services.VarietyElements, provider, model string) string {
2141
7x
    var summaryFields []string
2142
7x
    if variety != nil {
2143
5x
        if variety.GrammarFocus != "" {
2144
5x
            summaryFields = append(summaryFields, "grammar: "+variety.GrammarFocus)
2145
5x
        }
2146
5x
        if variety.TopicCategory != "" {
2147
2x
            summaryFields = append(summaryFields, "topic: "+variety.TopicCategory)
2148
2x
        }
2149
5x
        if variety.Scenario != "" {
2150
1x
            summaryFields = append(summaryFields, "scenario: "+variety.Scenario)
2151
1x
        }
2152
5x
        if variety.StyleModifier != "" {
2153
3x
            summaryFields = append(summaryFields, "style: "+variety.StyleModifier)
2154
3x
        }
2155
5x
        if variety.DifficultyModifier != "" {
2156
1x
            summaryFields = append(summaryFields, "difficulty: "+variety.DifficultyModifier)
2157
1x
        }
2158
5x
        if variety.VocabularyDomain != "" {
2159
3x
            summaryFields = append(summaryFields, "vocab: "+variety.VocabularyDomain)
2160
3x
        }
2161
5x
        if variety.TimeContext != "" {
2162
1x
            summaryFields = append(summaryFields, "time: "+variety.TimeContext)
2163
1x
        }
2164
    }
2165
7x
    providerModel := "provider: " + provider + ", model: " + model
2166
7x
    if len(summaryFields) > 0 {
2167
5x
        summaryFields = append(summaryFields, providerModel)
2168
5x
    } else {
2169
1x
        summaryFields = []string{providerModel}
2170
1x
    }
2171
7x
    return fmt.Sprintf("Worker [user=%s]: Batch %d %s questions (lang: %s, level: %s) | %s", username, count, qType, language, level, strings.Join(summaryFields, " | "))
2172
}
2173

2174
// PriorityGenerationData contains priority information to guide AI question generation
2175
type PriorityGenerationData struct {
2176
    UserWeakAreas        []string                        `json:"user_weak_areas,omitempty"`
2177
    HighPriorityTopics   []string                        `json:"high_priority_topics,omitempty"`
2178
    GapAnalysis          map[string]int                  `json:"gap_analysis,omitempty"`
2179
    UserPreferences      *models.UserLearningPreferences `json:"user_preferences,omitempty"`
2180
    PriorityDistribution map[string]int                  `json:"priority_distribution,omitempty"`
2181
    FocusOnWeakAreas     bool                            `json:"focus_on_weak_areas"`
2182
    FreshQuestionRatio   float64                         `json:"fresh_question_ratio"`
2183
}
2184

2185
// PriorityGenerationLog contains structured data about priority-aware generation decisions
2186
type PriorityGenerationLog struct {
2187
    UserID              int                       `json:"user_id"`
2188
    Username            string                    `json:"username"`
2189
    Language            string                    `json:"language"`
2190
    Level               string                    `json:"level"`
2191
    QuestionType        string                    `json:"question_type"`
2192
    FocusOnWeakAreas    bool                      `json:"focus_on_weak_areas"`
2193
    UserWeakAreas       []string                  `json:"user_weak_areas,omitempty"`
2194
    HighPriorityTopics  []string                  `json:"high_priority_topics,omitempty"`
2195
    GapAnalysis         map[string]int            `json:"gap_analysis,omitempty"`
2196
    FreshQuestionRatio  float64                   `json:"fresh_question_ratio"`
2197
    SelectedVariety     *services.VarietyElements `json:"selected_variety"`
2198
    GenerationReasoning string                    `json:"generation_reasoning"`
2199
    Timestamp           time.Time                 `json:"timestamp"`
2200
}
2201

2202
// logPriorityGeneration logs priority generation data as JSON
2203
1x
func (w *Worker) logPriorityGeneration(ctx context.Context, priorityLog PriorityGenerationLog) {
2204
1x
    ctx, span := observability.TraceWorkerFunction(ctx, "log_priority_generation",
2205
1x
        observability.AttributeUserID(priorityLog.UserID),
2206
1x
        attribute.String("user.username", priorityLog.Username),
2207
1x
        attribute.String("language", priorityLog.Language),
2208
1x
        attribute.String("level", priorityLog.Level),
2209
1x
        attribute.String("question.type", priorityLog.QuestionType),
2210
1x
        attribute.String("worker.instance", w.instance),
2211
1x
    )
2212
1x
    defer observability.FinishSpan(span, nil)
2213
1x

2214
1x
    logJSON, err := json.Marshal(priorityLog)
2215
1x
    if err != nil {
2216
        span.RecordError(err)
2217
        w.logger.Error(ctx, "Failed to marshal priority generation log", err, map[string]interface{}{
2218
            "instance": w.instance,
2219
        })
2220
        return
2221
    }
2222
1x
    w.logger.Info(ctx, "Worker priority generation", map[string]interface{}{
2223
1x
        "instance": w.instance,
2224
1x
        "data":     string(logJSON),
2225
1x
    })
2226
}
2227

2228
// getGenerationReasoning provides a human-readable explanation of the generation strategy
2229
17x
func (w *Worker) getGenerationReasoning(priorityData *PriorityGenerationData, variety *services.VarietyElements) string {
2230
17x
    if priorityData == nil {
2231
1x
        return "standard generation"
2232
1x
    }
2233
15x
    var reasons []string
2234
15x

2235
15x
    if priorityData.FocusOnWeakAreas && len(priorityData.UserWeakAreas) > 0 {
2236
2x
        reasons = append(reasons, fmt.Sprintf("focusing on weak areas: %s", strings.Join(priorityData.UserWeakAreas, ", ")))
2237
2x
    }
2238

2239
15x
    if len(priorityData.HighPriorityTopics) > 0 {
2240
2x
        reasons = append(reasons, fmt.Sprintf("high priority topics: %s", strings.Join(priorityData.HighPriorityTopics, ", ")))
2241
2x
    }
2242

2243
15x
    if len(priorityData.GapAnalysis) > 0 {
2244
2x
        var gaps []string
2245
2x
        for topic, count := range priorityData.GapAnalysis {
2246
2x
            gaps = append(gaps, fmt.Sprintf("%s(%d)", topic, count))
2247
2x
        }
2248
2x
        reasons = append(reasons, fmt.Sprintf("gap analysis: %s", strings.Join(gaps, ", ")))
2249
    }
2250

2251
15x
    if priorityData.FreshQuestionRatio > 0 {
2252
9x
        reasons = append(reasons, fmt.Sprintf("fresh ratio: %.1f%%", priorityData.FreshQuestionRatio*100))
2253
9x
    }
2254

2255
15x
    if variety != nil {
2256
1x
        var varietyElements []string
2257
1x
        if variety.TopicCategory != "" {
2258
            varietyElements = append(varietyElements, fmt.Sprintf("topic:%s", variety.TopicCategory))
2259
        }
2260
1x
        if variety.GrammarFocus != "" {
2261
1x
            varietyElements = append(varietyElements, fmt.Sprintf("grammar:%s", variety.GrammarFocus))
2262
1x
        }
2263
1x
        if variety.VocabularyDomain != "" {
2264
1x
            varietyElements = append(varietyElements, fmt.Sprintf("vocab:%s", variety.VocabularyDomain))
2265
1x
        }
2266
1x
        if variety.Scenario != "" {
2267
            varietyElements = append(varietyElements, fmt.Sprintf("scenario:%s", variety.Scenario))
2268
        }
2269
1x
        if len(varietyElements) > 0 {
2270
1x
            reasons = append(reasons, fmt.Sprintf("variety: %s", strings.Join(varietyElements, ", ")))
2271
1x
        }
2272
    }
2273

2274
15x
    if len(reasons) == 0 {
2275
        return "standard generation"
2276
    }
2277

2278
15x
    return strings.Join(reasons, "; ")
2279
}
2280

2281
// getPriorityGenerationData gathers priority data for AI question generation
2282
1x
func (w *Worker) getPriorityGenerationData(ctx context.Context, userID int, language, level string, questionType models.QuestionType) *PriorityGenerationData {
2283
1x
    // Get user preferences
2284
1x
    prefs, err := w.learningService.GetUserLearningPreferences(ctx, userID)
2285
1x
    if err != nil {
2286
        w.logger.Warn(ctx, "Worker failed to get user preferences", map[string]interface{}{
2287
            "instance": w.instance,
2288
            "user_id":  userID,
2289
            "error":    err.Error(),
2290
        })
2291
        prefs = w.getDefaultLearningPreferences()
2292
    }
2293

2294
    // Get weak areas
2295
1x
    weakAreas, err := w.learningService.GetUserWeakAreas(ctx, userID, 5)
2296
1x
    if err != nil {
2297
        w.logger.Warn(ctx, "Worker failed to get weak areas", map[string]interface{}{
2298
            "instance": w.instance,
2299
            "user_id":  userID,
2300
            "error":    err.Error(),
2301
        })
2302
        weakAreas = []map[string]interface{}{}
2303
    }
2304

2305
    // Convert weak areas to topic strings
2306
1x
    var weakAreaTopics []string
2307
1x
    for _, area := range weakAreas {
2308
        if topic, ok := area["topic"].(string); ok && topic != "" {
2309
            weakAreaTopics = append(weakAreaTopics, topic)
2310
        }
2311
    }
2312

2313
    // Get high priority topics
2314
1x
    highPriorityTopics, err := w.getHighPriorityTopics(ctx, userID, language, level, questionType)
2315
1x
    if err != nil {
2316
        w.logger.Warn(ctx, "Worker failed to get high priority topics", map[string]interface{}{
2317
            "instance": w.instance,
2318
            "user_id":  userID,
2319
            "error":    err.Error(),
2320
        })
2321
        highPriorityTopics = []string{}
2322
    }
2323

2324
    // Get gap analysis
2325
1x
    gapAnalysis, err := w.getGapAnalysis(ctx, userID, language, level, questionType)
2326
1x
    if err != nil {
2327
        w.logger.Warn(ctx, "Worker failed to get gap analysis", map[string]interface{}{
2328
            "instance": w.instance,
2329
            "user_id":  userID,
2330
            "error":    err.Error(),
2331
        })
2332
        gapAnalysis = map[string]int{}
2333
    }
2334

2335
    // Get priority distribution
2336
1x
    priorityDistribution, err := w.getPriorityDistribution(ctx, userID, language, level, questionType)
2337
1x
    if err != nil {
2338
        w.logger.Warn(ctx, "Worker failed to get priority distribution", map[string]interface{}{
2339
            "instance": w.instance,
2340
            "user_id":  userID,
2341
            "error":    err.Error(),
2342
        })
2343
        priorityDistribution = map[string]int{}
2344
    }
2345

2346
    // Determine if we should focus on weak areas
2347
1x
    focusOnWeakAreas := len(weakAreaTopics) > 0 && prefs != nil && prefs.FocusOnWeakAreas
2348
1x

2349
1x
    return &PriorityGenerationData{
2350
1x
        UserWeakAreas:        weakAreaTopics,
2351
1x
        HighPriorityTopics:   highPriorityTopics,
2352
1x
        GapAnalysis:          gapAnalysis,
2353
1x
        UserPreferences:      prefs,
2354
1x
        PriorityDistribution: priorityDistribution,
2355
1x
        FocusOnWeakAreas:     focusOnWeakAreas,
2356
1x
        FreshQuestionRatio:   prefs.FreshQuestionRatio,
2357
1x
    }
2358
}
2359

2360
// getDefaultLearningPreferences returns default learning preferences
2361
func (w *Worker) getDefaultLearningPreferences() *models.UserLearningPreferences {
2362
    return &models.UserLearningPreferences{
2363
        FocusOnWeakAreas:   false,
2364
        FreshQuestionRatio: 0.3,
2365
        WeakAreaBoost:      1.5,
2366
    }
2367
}
2368

2369
// getHighPriorityTopics returns topics that have high average priority scores
2370
20x
func (w *Worker) getHighPriorityTopics(ctx context.Context, userID int, language, level string, questionType models.QuestionType) (result0 []string, err error) {
2371
20x
    return w.workerService.GetHighPriorityTopics(ctx, userID, language, level, string(questionType))
2372
20x
}
2373

2374
// getGapAnalysis identifies areas with insufficient questions available
2375
23x
func (w *Worker) getGapAnalysis(ctx context.Context, userID int, language, level string, questionType models.QuestionType) (result0 map[string]int, err error) {
2376
23x
    return w.workerService.GetGapAnalysis(ctx, userID, language, level, string(questionType))
2377
23x
}
2378

2379
// getPriorityDistribution returns the distribution of priority scores
2380
20x
func (w *Worker) getPriorityDistribution(ctx context.Context, userID int, language, level string, questionType models.QuestionType) (result0 map[string]int, err error) {
2381
20x
    return w.workerService.GetPriorityDistribution(ctx, userID, language, level, string(questionType))
2382
20x
}
2383